From 8f4696bdfb6f47307a1f36a7f79c34af1cbb966f Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Fri, 17 Apr 2026 21:12:17 -0700
Subject: [PATCH] =?UTF-8?q?feat(composio):=20per-toolkit=20tool=20curation?=
=?UTF-8?q?,=20user=20scopes,=20and=20Gmail=20HTML=E2=86=92markdown=20(#64?=
=?UTF-8?q?3)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(composio): add user scope management for toolkits
- Introduced `get_user_scopes` and `set_user_scopes` functions to manage per-toolkit user scope preferences, allowing for read, write, and admin classifications.
- Updated `all_controller_schemas` and `all_registered_controllers` to include new schemas for user scope management.
- Implemented `evaluate_tool_visibility` to determine tool visibility based on user-defined scopes, enhancing security and control over tool actions.
- Added `UserScopePref` struct to store user preferences and integrated it with memory storage for persistence.
- Enhanced existing tools to respect user scope preferences during execution, ensuring actions align with user-defined permissions.
These changes improve the flexibility and security of toolkit interactions, allowing users to customize their access levels for different actions.
* feat(gmail): expand GMAIL_CURATED tools for enhanced email management
- Updated the GMAIL_CURATED constant to include additional tools for reading and writing emails, such as GMAIL_LIST_MESSAGES, GMAIL_LIST_THREADS, GMAIL_GET_ATTACHMENT, and GMAIL_FORWARD_MESSAGE.
- Improved organization of tools by categorizing them under distinct sections for reading messages, managing drafts, and handling labels.
- Enhanced admin tools with new functionalities like GMAIL_BATCH_DELETE_MESSAGES and GMAIL_UNTRASH_THREAD, improving overall email management capabilities.
These changes provide a more comprehensive toolkit for interacting with Gmail, enhancing user experience and functionality.
* refactor(tool_scope, gmail): improve code formatting and organization
- Reformatted the `ADMIN` and `GMAIL_CURATED` constants for better readability by aligning the entries vertically.
- Enhanced the clarity of the `classify_unknown` function by improving the structure of the constants, making it easier to maintain and understand.
- Updated test assertions in `toolkit_from_slug` for consistency in formatting, ensuring clearer test outputs.
These changes improve the overall readability and maintainability of the codebase, aligning with ongoing refactoring efforts.
* feat(github): add GitHub toolkit and curated tools for enhanced integration
- Introduced a new GitHub provider module, including a curated catalog of GitHub actions tailored for common tasks such as repository management, issue tracking, and pull request handling.
- Implemented the `catalog_for_toolkit` function to allow fallback to a static curated list for toolkits without a native provider, ensuring consistent tool visibility and access.
- Updated the `evaluate_tool_visibility` function to prioritize curated tools from registered providers, enhancing the overall user experience and security by enforcing whitelist checks.
These changes expand the capabilities of the Composio toolkit, providing users with a comprehensive set of tools for interacting with GitHub, while maintaining a focus on user-defined permissions and visibility.
* refactor(tools): improve formatting and organization of curated tools
- Reformatted the `GITHUB_CURATED`, `NOTION_CURATED`, and other tool constants for better readability by aligning entries vertically.
- Enhanced the clarity of the code structure, making it easier to maintain and understand.
- Updated related documentation to reflect the changes in formatting and organization.
These changes improve the overall readability and maintainability of the codebase, aligning with ongoing refactoring efforts.
* feat(catalogs): introduce curated catalogs for various toolkits
- Added a new module `catalogs.rs` containing curated tool lists for Slack, Discord, Google Calendar, Google Drive, Google Docs, and more, enhancing the Composio toolkit's integration capabilities.
- Updated the `mod.rs` file to include the new `catalogs` module and modified the `catalog_for_toolkit` function to support these curated lists, allowing for better organization and access to toolkit actions.
- This addition improves the overall functionality and user experience by providing a comprehensive set of tools for interacting with popular platforms.
* feat(post-process): implement HTML to markdown conversion for Gmail responses
- Introduced a new `post_process` module to handle per-toolkit response modifications, specifically for converting HTML content to markdown format.
- Enhanced the `ComposioExecuteTool` to apply post-processing on successful responses, improving the clarity and usability of data returned from Gmail.
- Added tests to ensure the correct functionality of HTML detection and conversion, validating the integrity of the post-processing logic.
These changes enhance the user experience by streamlining the handling of HTML content in responses, making it more suitable for further processing and display.
* refactor(post_process): reorganize HTML detection constants for improved readability
- Moved HTML detection markers in the `looks_like_html` function to a more structured format, enhancing clarity and maintainability.
- This change aligns with ongoing efforts to improve code organization and readability within the post-processing module.
* refactor(catalogs): enhance formatting and organization of curated tool constants
- Reformatted the `SLACK_CURATED`, `DISCORD_CURATED`, and `GOOGLECALENDAR_CURATED` constants for improved readability by aligning entries vertically.
- This change enhances the clarity and maintainability of the code, aligning with ongoing refactoring efforts to improve code organization.
* feat(connect-modal): wire read/write/admin scope toggles to composio prefs
Adds the three scope toggles to the connected-state of the
ComposioConnectModal so users can gate which Composio actions the
agent may invoke per integration. Loads the stored pref via
`composio_get_user_scopes` once the modal lands in the connected
phase and persists changes through `composio_set_user_scopes` with
optimistic updates and rollback on error.
- Adds `getUserScopes` / `setUserScopes` to composioApi.
- Adds `ComposioUserScopePref` type mirror.
- Renders accessible role="switch" toggles with hint text per row.
* refactor(ComposioConnectModal): simplify SCOPE_ROWS definition
- Streamlined the definition of SCOPE_ROWS in ComposioConnectModal by removing unnecessary line breaks, enhancing code readability and maintainability.
- Updated the agent.toml configuration to include "composio_execute" in the tools list, expanding the capabilities of the integrations agent.
* fix(composio): apply curated whitelist + scope pref to integrations_agent prompt
The agent prompt for integrations_agent was rendering every action
returned by the backend's `composio_list_tools` for each connected
toolkit, bypassing the curation/scope filter that the meta-tool layer
applies. Concretely the GitHub integrations_agent prompt was showing
~500 actions including non-curated entries like
GITHUB_ADD_OR_UPDATE_TEAM_REPOSITORY_PERMISSIONS.
Adds `is_action_visible_with_pref(slug, pref)` — a sync helper that
mirrors the meta-tool layer's decision logic — and applies it in:
- `fetch_connected_integrations_uncached` (bulk session-cached path)
- `fetch_toolkit_actions` (per-toolkit spawn-time path)
One pref load per toolkit (not per action) keeps the cost minimal.
* refactor(fetch_connected_integrations): streamline action visibility filter
Simplified the action visibility filter in `fetch_connected_integrations_uncached` by consolidating the filter logic into a single line. This change enhances code readability while maintaining the existing functionality of applying user preferences to the displayed actions.
* fix(prompt): drop duplicate `### Available Tools` listing in text-mode preamble
Text-mode subagent prompts were rendering the tool catalog twice: once
in the prompt template's `## Tools` section (with the richer
`Call as: NAME[arg|arg]` signatures from `prompts::ToolsSection::build`)
and once in `### Available Tools` under `## Tool Use Protocol`
(`Parameters: name:type, ...` format).
For an integrations_agent toolkit spawn (~50 actions) this doubled the
tool listing bytes for no informational gain. Keep only the protocol
preamble (essential for text mode); the catalog stays in `## Tools`.
Removes `summarise_parameters` and `first_line_truncated` which were
the sole consumers, plus the now-unused `std::fmt::Write` import.
* review: address PR feedback (UTF-8 boundary, structured logs, doc, debug)
Real fixes:
- post_process: walk back to UTF-8 char boundary before truncating to
4096 bytes; previous `&s[..4096]` could panic mid-codepoint. Adds
regression test that places a 3-byte char straddling the cutoff.
- composioApi: move misplaced `execute` docstring back above `execute`
(it had drifted above the new `getUserScopes`).
- schemas: include `get_user_scopes` / `set_user_scopes` in the
`every_known_schema_key_resolves` test.
Diagnosability:
- schemas: structured `[composio:scopes]` debug/error logs at entry,
exit, and every early-return in `handle_get_user_scopes` /
`handle_set_user_scopes` (method + toolkit + pref fields). The
memory-not-ready branch now logs an error before returning.
- composioApi + ConnectModal: grep-friendly `[composio][scopes]` debug
logs around getUserScopes / setUserScopes RPC round-trips and the
toggle handler (old → new state, persisted result, errors).
- user_scopes: load_or_default now logs the same normalized `key` that
`load()` does, so traces correlate across both code paths.
Cleanups:
- tools: replace external `idx` + `Vec::retain` in
`filter_list_tools_response` with a drain + zip + filter_map pattern.
- tool_scope: document the no-underscore-in-toolkit-name assumption of
`toolkit_from_slug`, and call out the `microsoft_teams` alias.
- Cargo.toml: comment on the html2md vs htmd choice.
Pushback (no change):
- Reviewer asked to split the type-only import in ConnectModal into a
separate `import type` line; ESLint's `no-duplicate-imports` rejects
that, so the inline `type` form (functionally identical) stays.
---
Cargo.lock | 90 +-
Cargo.toml | 7 +
.../composio/ComposioConnectModal.tsx | 179 +-
app/src/lib/composio/composioApi.ts | 48 +
app/src/lib/composio/types.ts | 10 +
.../agents/integrations_agent/agent.toml | 2 +-
.../agent/harness/subagent_runner.rs | 76 +-
src/openhuman/composio/ops.rs | 76 +-
src/openhuman/composio/providers/catalogs.rs | 2048 +++++++++++++++++
.../composio/providers/github/mod.rs | 11 +
.../composio/providers/github/tools.rs | 188 ++
src/openhuman/composio/providers/gmail/mod.rs | 2 +
.../composio/providers/gmail/provider.rs | 7 +-
.../composio/providers/gmail/tools.rs | 145 ++
src/openhuman/composio/providers/mod.rs | 83 +
.../composio/providers/notion/mod.rs | 2 +
.../composio/providers/notion/provider.rs | 7 +-
.../composio/providers/notion/tools.rs | 196 ++
.../composio/providers/post_process.rs | 188 ++
.../composio/providers/tool_scope.rs | 185 ++
src/openhuman/composio/providers/traits.rs | 17 +
.../composio/providers/user_scopes.rs | 201 ++
src/openhuman/composio/schemas.rs | 159 ++
src/openhuman/composio/tools.rs | 149 +-
24 files changed, 3967 insertions(+), 109 deletions(-)
create mode 100644 src/openhuman/composio/providers/catalogs.rs
create mode 100644 src/openhuman/composio/providers/github/mod.rs
create mode 100644 src/openhuman/composio/providers/github/tools.rs
create mode 100644 src/openhuman/composio/providers/gmail/tools.rs
create mode 100644 src/openhuman/composio/providers/notion/tools.rs
create mode 100644 src/openhuman/composio/providers/post_process.rs
create mode 100644 src/openhuman/composio/providers/tool_scope.rs
create mode 100644 src/openhuman/composio/providers/user_scopes.rs
diff --git a/Cargo.lock b/Cargo.lock
index a74f3d683..08914917b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1134,7 +1134,7 @@ dependencies = [
"core-foundation-sys 0.8.7",
"coreaudio-rs",
"dasp_sample",
- "jni",
+ "jni 0.21.1",
"js-sys",
"libc",
"mach2",
@@ -2409,6 +2409,34 @@ version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
+[[package]]
+name = "html2md"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4"
+dependencies = [
+ "html5ever 0.27.0",
+ "jni 0.19.0",
+ "lazy_static",
+ "markup5ever_rcdom",
+ "percent-encoding",
+ "regex",
+]
+
+[[package]]
+name = "html5ever"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever 0.12.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "html5ever"
version = "0.35.0"
@@ -2416,7 +2444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
- "markup5ever",
+ "markup5ever 0.35.0",
"match_token",
]
@@ -2960,6 +2988,20 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "jni"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
+dependencies = [
+ "cesu8",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+]
+
[[package]]
name = "jni"
version = "0.21.1"
@@ -3318,6 +3360,20 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+[[package]]
+name = "markup5ever"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
+dependencies = [
+ "log",
+ "phf 0.11.3",
+ "phf_codegen 0.11.3",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
[[package]]
name = "markup5ever"
version = "0.35.0"
@@ -3329,6 +3385,18 @@ dependencies = [
"web_atoms",
]
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18"
+dependencies = [
+ "html5ever 0.27.0",
+ "markup5ever 0.12.1",
+ "tendril",
+ "xml5ever",
+]
+
[[package]]
name = "match_token"
version = "0.35.0"
@@ -4270,7 +4338,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
- "jni",
+ "jni 0.21.1",
"ndk",
"ndk-context",
"num-derive",
@@ -4339,6 +4407,7 @@ dependencies = [
"hmac 0.12.1",
"hostname",
"hound",
+ "html2md",
"image",
"landlock",
"lettre",
@@ -5631,7 +5700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6dcd6e9823e177d15460d3cd3a413f38a2beea381f26aca1001c05cd6954ff"
dependencies = [
"as_variant",
- "html5ever",
+ "html5ever 0.35.0",
"tracing",
"wildmatch",
]
@@ -5773,7 +5842,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys 0.8.7",
- "jni",
+ "jni 0.21.1",
"log",
"once_cell",
"rustls",
@@ -8488,6 +8557,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+[[package]]
+name = "xml5ever"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever 0.12.1",
+]
+
[[package]]
name = "xxhash-rust"
version = "0.8.15"
diff --git a/Cargo.toml b/Cargo.toml
index 9a319398e..b8f5463b0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,13 @@ crate-type = ["rlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+# Used by composio post-processing to convert Gmail HTML bodies to
+# markdown. `html2md` is the canonical pure-Rust crate (no system deps);
+# `htmd` (>0.5) is more actively maintained but our usage is one
+# function call (`parse_html`) and the migration cost outweighs the
+# upside today. Re-evaluate if html2md goes unmaintained or we need
+# GFM/table fidelity beyond what html2md emits.
+html2md = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] }
tokio = { version = "1", features = ["full", "sync"] }
once_cell = "1.19"
diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx
index b8c229385..4a2edd2cd 100644
--- a/app/src/components/composio/ComposioConnectModal.tsx
+++ b/app/src/components/composio/ComposioConnectModal.tsx
@@ -16,8 +16,18 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import { authorize, deleteConnection, listConnections } from '../../lib/composio/composioApi';
-import { type ComposioConnection, deriveComposioState } from '../../lib/composio/types';
+import {
+ authorize,
+ deleteConnection,
+ getUserScopes,
+ listConnections,
+ setUserScopes,
+} from '../../lib/composio/composioApi';
+import {
+ type ComposioConnection,
+ type ComposioUserScopePref,
+ deriveComposioState,
+} from '../../lib/composio/types';
import { openUrl } from '../../utils/openUrl';
import type { ComposioToolkitMeta } from './toolkitMeta';
@@ -58,6 +68,16 @@ export default function ComposioConnectModal({
connection
);
+ // ── Scope preferences (read/write/admin) ────────────────────────
+ // The pref gates which curated Composio actions the agent may call.
+ // We load it lazily once the toolkit is connected, so the toggles in
+ // the success view always reflect what the core actually has stored.
+ const [scopes, setScopes] = useState(null);
+ const [scopeError, setScopeError] = useState(null);
+ // Per-key in-flight flag so spamming a single toggle disables only
+ // that row while the RPC round-trips.
+ const [savingScope, setSavingScope] = useState(null);
+
// Escape to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@@ -175,6 +195,78 @@ export default function ComposioConnectModal({
}
}, [startPolling, toolkit.slug]);
+ // Fetch the stored scope pref whenever the modal lands in the
+ // 'connected' phase. Re-fetching each time we transition (rather
+ // than once on mount) keeps the toggles correct after a fresh OAuth
+ // handoff completes inside this modal.
+ useEffect(() => {
+ if (phase !== 'connected') return;
+ let cancelled = false;
+ void (async () => {
+ try {
+ const pref = await getUserScopes(toolkit.slug);
+ if (!cancelled) setScopes(pref);
+ } catch (err) {
+ if (!cancelled) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setScopeError(`Couldn't load scope preferences: ${msg}`);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [phase, toolkit.slug]);
+
+ const handleToggleScope = useCallback(
+ async (key: keyof ComposioUserScopePref) => {
+ if (!scopes || savingScope) {
+ console.debug(
+ '[composio][scopes] toggle ignored toolkit=%s key=%s reason=%s',
+ toolkit.slug,
+ key,
+ !scopes ? 'pref-not-loaded' : 'another-save-in-flight'
+ );
+ return;
+ }
+ const optimistic: ComposioUserScopePref = { ...scopes, [key]: !scopes[key] };
+ console.debug(
+ '[composio][scopes] toggle toolkit=%s key=%s old=%s new=%s',
+ toolkit.slug,
+ key,
+ scopes[key],
+ optimistic[key]
+ );
+ setScopes(optimistic);
+ setSavingScope(key);
+ setScopeError(null);
+ try {
+ const persisted = await setUserScopes(toolkit.slug, optimistic);
+ console.debug(
+ '[composio][scopes] toggle persisted toolkit=%s key=%s pref=%o',
+ toolkit.slug,
+ key,
+ persisted
+ );
+ setScopes(persisted);
+ } catch (err) {
+ // Roll back on failure so the toggle reflects reality.
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(
+ '[composio][scopes] toggle failed toolkit=%s key=%s error=%o',
+ toolkit.slug,
+ key,
+ err
+ );
+ setScopes(scopes);
+ setScopeError(`Couldn't save ${key} scope: ${msg}`);
+ } finally {
+ setSavingScope(null);
+ }
+ },
+ [savingScope, scopes, toolkit.slug]
+ );
+
const handleDisconnect = useCallback(async () => {
if (!activeConnection) return;
setPhase('disconnecting');
@@ -299,6 +391,12 @@ export default function ComposioConnectModal({
id: {activeConnection.id}
)}
+
void handleDisconnect()}
@@ -333,3 +431,80 @@ export default function ComposioConnectModal({
return createPortal(modalContent, document.body);
}
+
+// ── Scope toggles ───────────────────────────────────────────────────
+
+const SCOPE_ROWS: Array<{ key: keyof ComposioUserScopePref; label: string; hint: string }> = [
+ {
+ key: 'read',
+ label: 'Read',
+ hint: 'Allow listing, fetching, searching (e.g. read emails / pages).',
+ },
+ {
+ key: 'write',
+ label: 'Write',
+ hint: 'Allow sending, creating, updating (e.g. send emails, create pages).',
+ },
+ {
+ key: 'admin',
+ label: 'Admin',
+ hint: 'Allow destructive or permission-changing actions (delete, share, etc.).',
+ },
+];
+
+interface ScopeTogglesProps {
+ scopes: ComposioUserScopePref | null;
+ savingScope: keyof ComposioUserScopePref | null;
+ onToggle: (key: keyof ComposioUserScopePref) => void;
+ error: string | null;
+}
+
+function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProps) {
+ // Render skeleton placeholders while we wait on the initial load so
+ // the modal layout doesn't jump when the pref arrives.
+ const loading = scopes === null;
+
+ return (
+
+
+
+ Agent permissions
+
+
Read+Write enabled by default
+
+
+ {SCOPE_ROWS.map(row => {
+ const enabled = scopes?.[row.key] ?? false;
+ const isSaving = savingScope === row.key;
+ return (
+
+
+
{row.label}
+
{row.hint}
+
+ onToggle(row.key)}
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${
+ enabled ? 'bg-primary-500' : 'bg-stone-300'
+ }`}>
+
+
+
+ );
+ })}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/app/src/lib/composio/composioApi.ts b/app/src/lib/composio/composioApi.ts
index d204f6a08..a682017e5 100644
--- a/app/src/lib/composio/composioApi.ts
+++ b/app/src/lib/composio/composioApi.ts
@@ -18,6 +18,7 @@ import type {
ComposioExecuteResponse,
ComposioToolkitsResponse,
ComposioToolsResponse,
+ ComposioUserScopePref,
} from './types';
/**
@@ -89,6 +90,53 @@ export async function deleteConnection(connectionId: string): Promise(raw);
}
+/**
+ * Read the per-toolkit user scope preference (read/write/admin) used
+ * to gate `composio_execute`. Returns the default
+ * `{ read: true, write: true, admin: false }` when nothing is stored.
+ */
+export async function getUserScopes(toolkit: string): Promise {
+ console.debug('[composio][scopes] → openhuman.composio_get_user_scopes toolkit=%s', toolkit);
+ const raw = await callCoreRpc({
+ method: 'openhuman.composio_get_user_scopes',
+ params: { toolkit },
+ });
+ const pref = unwrapCliEnvelope(raw);
+ console.debug(
+ '[composio][scopes] ← openhuman.composio_get_user_scopes toolkit=%s pref=%o',
+ toolkit,
+ pref
+ );
+ return pref;
+}
+
+/**
+ * Persist a per-toolkit user scope preference. The agent will only be
+ * able to invoke composio actions whose classified scope is enabled
+ * here.
+ */
+export async function setUserScopes(
+ toolkit: string,
+ pref: ComposioUserScopePref
+): Promise {
+ console.debug(
+ '[composio][scopes] → openhuman.composio_set_user_scopes toolkit=%s pref=%o',
+ toolkit,
+ pref
+ );
+ const raw = await callCoreRpc({
+ method: 'openhuman.composio_set_user_scopes',
+ params: { toolkit, ...pref },
+ });
+ const persisted = unwrapCliEnvelope(raw);
+ console.debug(
+ '[composio][scopes] ← openhuman.composio_set_user_scopes toolkit=%s persisted=%o',
+ toolkit,
+ persisted
+ );
+ return persisted;
+}
+
/**
* Execute a Composio action slug (e.g. `GMAIL_SEND_EMAIL`). The core
* charges the caller, tracks usage, and publishes a
diff --git a/app/src/lib/composio/types.ts b/app/src/lib/composio/types.ts
index 455ba6f26..aa6145a57 100644
--- a/app/src/lib/composio/types.ts
+++ b/app/src/lib/composio/types.ts
@@ -56,6 +56,16 @@ export interface ComposioExecuteResponse {
costUsd: number;
}
+/**
+ * Per-toolkit scope preference stored in the core's KV. Default is
+ * `{ read: true, write: true, admin: false }`.
+ */
+export interface ComposioUserScopePref {
+ read: boolean;
+ write: boolean;
+ admin: boolean;
+}
+
// ── UI helpers ────────────────────────────────────────────────────
/**
diff --git a/src/openhuman/agent/agents/integrations_agent/agent.toml b/src/openhuman/agent/agents/integrations_agent/agent.toml
index 5d0e2eba6..ff5464c71 100644
--- a/src/openhuman/agent/agents/integrations_agent/agent.toml
+++ b/src/openhuman/agent/agents/integrations_agent/agent.toml
@@ -13,4 +13,4 @@ omit_skills_catalog = true
hint = "agentic"
[tools]
-named = ["composio_list_tools", "file_read"]
+named = ["composio_list_tools", "file_read", "composio_execute"]
diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs
index 5f13d4b72..feca8dd5e 100644
--- a/src/openhuman/agent/harness/subagent_runner.rs
+++ b/src/openhuman/agent/harness/subagent_runner.rs
@@ -40,7 +40,6 @@ use async_trait::async_trait;
use futures::stream::StreamExt;
use regex::Regex;
use std::collections::{HashMap, HashSet};
-use std::fmt::Write as _;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, Instant};
use thiserror::Error;
@@ -1731,7 +1730,16 @@ fn top_k_for_toolkit(toolkit: &str) -> usize {
/// correctly while staying within budget. If the model needs deeper
/// schema detail it can surface the error and the orchestrator will
/// clarify on the next turn.
-pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String {
+pub(crate) fn build_text_mode_tool_instructions(_specs: &[ToolSpec]) -> String {
+ // The tool catalog is already rendered in the prompt's `## Tools`
+ // section (see `prompts::ToolsSection::build`) with full
+ // `Call as: NAME[arg|arg]` signatures. We previously also emitted
+ // an `### Available Tools` subsection here with a different
+ // formatting (`Parameters: name:type, ...`), which doubled the
+ // tool list bytes for text-mode agents — especially expensive for
+ // the integrations_agent toolkit-scoped spawns (~50 actions ×
+ // 2 listings). Keep only the protocol explanation; the tool
+ // catalog itself comes from the prompt template.
let mut out = String::new();
out.push_str("## Tool Use Protocol\n\n");
out.push_str(
@@ -1740,73 +1748,11 @@ pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String {
in the same response if you need to run calls in parallel.\n\n",
);
out.push_str(
- "```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n \n```\n\n",
+ "```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n \n```\n",
);
- out.push_str("### Available Tools\n\n");
- for spec in specs {
- let _ = writeln!(
- out,
- "- **{}**: {}",
- spec.name,
- first_line_truncated(&spec.description, 120)
- );
- let params_line = summarise_parameters(&spec.parameters);
- if !params_line.is_empty() {
- let _ = writeln!(out, " Parameters: {}", params_line);
- }
- }
out
}
-/// Render a JSON-schema `parameters` object as a single-line,
-/// ultra-compact parameter list — `*name: type, optName: type` for each
-/// top-level property (leading `*` marks required). Deeply nested
-/// shapes, enums, descriptions, and examples are all dropped.
-///
-/// Kept intentionally terse: Composio action schemas routinely contain
-/// per-parameter descriptions several hundred tokens long, so even a
-/// "short description" per param balloons to tens of thousands of
-/// tokens across a 27-tool integrations_agent toolkit and pushes the prompt
-/// past the 196 607-token context window. The model can infer usage
-/// from the parameter names + the tool's overall description; any
-/// validation mismatch surfaces at call time and the orchestrator can
-/// course-correct on the next turn.
-fn summarise_parameters(params: &serde_json::Value) -> String {
- let Some(props) = params.get("properties").and_then(|v| v.as_object()) else {
- return String::new();
- };
- let required: std::collections::HashSet<&str> = params
- .get("required")
- .and_then(|v| v.as_array())
- .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
- .unwrap_or_default();
-
- let mut parts: Vec = Vec::with_capacity(props.len());
- for (name, schema) in props {
- let ty = schema.get("type").and_then(|v| v.as_str()).unwrap_or("any");
- let marker = if required.contains(name.as_str()) {
- "*"
- } else {
- ""
- };
- parts.push(format!("{marker}{name}:{ty}"));
- }
- parts.join(", ")
-}
-
-/// Return the first line of `s`, trimmed and truncated to `max_chars`
-/// with a trailing ellipsis when it overflows. Used to keep
-/// tool/parameter descriptions on a single grep-friendly line.
-fn first_line_truncated(s: &str, max_chars: usize) -> String {
- let first = s.lines().next().unwrap_or("").trim();
- if first.chars().count() <= max_chars {
- first.to_string()
- } else {
- let truncated: String = first.chars().take(max_chars).collect();
- format!("{truncated}…")
- }
-}
-
// ─────────────────────────────────────────────────────────────────────────────
// Tool filtering
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs
index b8d6756b8..c573bf278 100644
--- a/src/openhuman/composio/ops.rs
+++ b/src/openhuman/composio/ops.rs
@@ -587,37 +587,49 @@ async fn fetch_connected_integrations_uncached(
// Build one entry per allowlisted toolkit. Connected entries
// carry their action catalogue; not-connected entries carry an
// empty `tools` vec.
- let mut integrations: Vec = unique_toolkits
- .iter()
- .map(|slug| {
- let connected = connected_slugs.contains(slug);
- // Anchor the prefix with an underscore so slugs that share
- // a text prefix (e.g. `git` vs `github`) don't false-match
- // each other's actions. `GMAIL_SEND_EMAIL` matches `gmail_`,
- // not just `gmail`, so siblings stay in their own buckets.
- let action_prefix = format!("{}_", slug.to_uppercase());
- let tools: Vec = if connected {
- tools_by_toolkit
- .iter()
- .filter(|t| t.function.name.starts_with(&action_prefix))
- .map(|t| ConnectedIntegrationTool {
- name: t.function.name.clone(),
- description: t.function.description.clone().unwrap_or_default(),
- parameters: t.function.parameters.clone(),
- })
- .collect()
- } else {
- Vec::new()
- };
+ let mut integrations: Vec = Vec::with_capacity(unique_toolkits.len());
+ for slug in &unique_toolkits {
+ let connected = connected_slugs.contains(slug);
+ // Anchor the prefix with an underscore so slugs that share
+ // a text prefix (e.g. `git` vs `github`) don't false-match
+ // each other's actions. `GMAIL_SEND_EMAIL` matches `gmail_`,
+ // not just `gmail`, so siblings stay in their own buckets.
+ let action_prefix = format!("{}_", slug.to_uppercase());
+ let tools: Vec = if connected {
+ // Apply the same curated-whitelist + user-scope filter the
+ // meta-tool layer uses, so the integrations_agent prompt
+ // only advertises actions the agent is actually allowed to
+ // call. One pref load per toolkit (not per action).
+ let pref = super::providers::load_user_scope_or_default(slug).await;
+ let filtered: Vec<&super::types::ComposioToolSchema> = tools_by_toolkit
+ .iter()
+ .filter(|t| t.function.name.starts_with(&action_prefix))
+ .filter(|t| super::providers::is_action_visible_with_pref(&t.function.name, &pref))
+ .collect();
+ tracing::debug!(
+ toolkit = %slug,
+ kept = filtered.len(),
+ "[composio][scopes] integrations prompt action set"
+ );
+ filtered
+ .into_iter()
+ .map(|t| ConnectedIntegrationTool {
+ name: t.function.name.clone(),
+ description: t.function.description.clone().unwrap_or_default(),
+ parameters: t.function.parameters.clone(),
+ })
+ .collect()
+ } else {
+ Vec::new()
+ };
- ConnectedIntegration {
- toolkit: slug.clone(),
- description: toolkit_description(slug).to_string(),
- tools,
- connected,
- }
- })
- .collect();
+ integrations.push(ConnectedIntegration {
+ toolkit: slug.clone(),
+ description: toolkit_description(slug).to_string(),
+ tools,
+ connected,
+ });
+ }
integrations.sort_by(|a, b| a.toolkit.cmp(&b.toolkit));
@@ -671,10 +683,14 @@ pub async fn fetch_toolkit_actions(
.await
.map_err(|e| anyhow::anyhow!("list_tools failed for toolkit `{toolkit_slug}`: {e}"))?;
let action_prefix = format!("{}_", toolkit_slug.to_uppercase());
+ // Apply curated whitelist + user scope so spawn-time tool
+ // discovery agrees with the bulk path and the meta-tool layer.
+ let pref = super::providers::load_user_scope_or_default(toolkit_slug).await;
let actions: Vec = resp
.tools
.into_iter()
.filter(|t| t.function.name.starts_with(&action_prefix))
+ .filter(|t| super::providers::is_action_visible_with_pref(&t.function.name, &pref))
.map(|t| ConnectedIntegrationTool {
name: t.function.name,
description: t.function.description.unwrap_or_default(),
diff --git a/src/openhuman/composio/providers/catalogs.rs b/src/openhuman/composio/providers/catalogs.rs
new file mode 100644
index 000000000..efcea1739
--- /dev/null
+++ b/src/openhuman/composio/providers/catalogs.rs
@@ -0,0 +1,2048 @@
+//! Curated catalogs for Composio toolkits that don't (yet) have a
+//! native [`super::ComposioProvider`] implementation.
+//!
+//! These slices are consulted by [`super::catalog_for_toolkit`] alongside
+//! provider-supplied catalogs (gmail, notion, github), so the meta-tool
+//! layer applies the same whitelist + scope filtering.
+//!
+//! Slugs sourced from `https://docs.composio.dev/toolkits/.md` —
+//! best-effort. Slugs that don't exist on the backend simply never
+//! appear in `composio_list_tools`, so extras are harmless.
+
+use super::tool_scope::{CuratedTool, ToolScope};
+
+// ── slack ───────────────────────────────────────────────────────────
+pub const SLACK_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "SLACK_FIND_CHANNELS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_FIND_USERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_FETCH_CONVERSATION_HISTORY",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_LIST_ALL_CHANNELS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_LIST_ALL_USERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_LIST_CONVERSATIONS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_FETCH_TEAM_INFO",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_GET_USER_PRESENCE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_ASSISTANT_SEARCH_CONTEXT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SLACK_SEND_MESSAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_POST_MESSAGE_TO_CHANNEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_SEND_MESSAGE_TO_CHANNEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_CREATE_CHANNEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_ADD_REACTION_TO_AN_ITEM",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_UPLOAD_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_CREATE_A_REMINDER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_CREATE_USER_GROUP",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SLACK_DELETE_CHANNEL",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SLACK_ARCHIVE_CONVERSATION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SLACK_DELETE_FILE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SLACK_DELETE_REMINDER",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SLACK_LEAVE_CONVERSATION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SLACK_INVITE_USER_TO_WORKSPACE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SLACK_CONVERT_CHANNEL_TO_PRIVATE",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── discord ─────────────────────────────────────────────────────────
+pub const DISCORD_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "DISCORD_GET_MY_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DISCORD_GET_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DISCORD_LIST_MY_GUILDS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DISCORD_GET_MY_GUILD_MEMBER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DISCORD_INVITE_RESOLVE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DISCORD_GET_GUILD_WIDGET",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DISCORD_LIST_MY_CONNECTIONS",
+ scope: ToolScope::Read,
+ },
+];
+
+// ── googlecalendar ──────────────────────────────────────────────────
+pub const GOOGLECALENDAR_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "GOOGLECALENDAR_EVENTS_LIST",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_FIND_EVENT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_LIST_CALENDARS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_EVENTS_GET",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_FIND_FREE_SLOTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_GET_CALENDAR",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_EVENTS_LIST_ALL_CALENDARS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_CREATE_EVENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_UPDATE_EVENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_PATCH_EVENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_QUICK_ADD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_EVENTS_MOVE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_REMOVE_ATTENDEE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_EVENTS_IMPORT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_DELETE_EVENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_CLEAR_CALENDAR",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_CALENDARS_DELETE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_DUPLICATE_CALENDAR",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_PATCH_CALENDAR",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_ACL_INSERT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLECALENDAR_ACL_DELETE",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── googledrive ─────────────────────────────────────────────────────
+pub const GOOGLEDRIVE_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "GOOGLEDRIVE_FIND_FILE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_LIST_FILES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_GET_FILE_METADATA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_DOWNLOAD_FILE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_LIST_PERMISSIONS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_FIND_FOLDER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_GET_ABOUT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_CREATE_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_CREATE_FOLDER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_UPLOAD_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_CREATE_FILE_FROM_TEXT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_COPY_FILE_ADVANCED",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_MOVE_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_EDIT_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_RENAME_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_CREATE_PERMISSION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_DELETE_PERMISSION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_UPDATE_PERMISSION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_DELETE_FILE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_GOOGLE_DRIVE_DELETE_FOLDER_OR_FILE_ACTION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDRIVE_EMPTY_TRASH",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── googledocs ──────────────────────────────────────────────────────
+pub const GOOGLEDOCS_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "GOOGLEDOCS_GET_DOCUMENT_BY_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_SEARCH_DOCUMENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_CREATE_DOCUMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_INSERT_TEXT_ACTION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_INSERT_TABLE_ACTION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_INSERT_INLINE_IMAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_UPDATE_EXISTING_DOCUMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_UPDATE_DOCUMENT_SECTION_MARKDOWN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_REPLACE_ALL_TEXT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_COPY_DOCUMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_CREATE_HEADER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_CREATE_FOOTER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_DELETE_CONTENT_RANGE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_DELETE_HEADER",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_DELETE_FOOTER",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_DELETE_NAMED_RANGE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_DELETE_TABLE_ROW",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLEDOCS_DELETE_TABLE_COLUMN",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── googlesheets ────────────────────────────────────────────────────
+pub const GOOGLESHEETS_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "GOOGLESHEETS_BATCH_GET",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_VALUES_GET",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_LOOKUP_SPREADSHEET_ROW",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_GET_SPREADSHEET_INFO",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_GET_SHEET_NAMES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_SEARCH_SPREADSHEETS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_VALUES_UPDATE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_UPDATE_VALUES_BATCH",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_APPEND",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_UPSERT_ROWS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_CREATE_GOOGLE_SHEET1",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_ADD_SHEET",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_CREATE_SPREADSHEET_ROW",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_CREATE_SPREADSHEET_COLUMN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_FIND_REPLACE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_FORMAT_CELL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_SET_DATA_VALIDATION_RULE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_BATCH_CLEAR",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_DELETE_SHEET",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_DELETE_DIMENSION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_UPDATE_SHEET_PROPERTIES",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GOOGLESHEETS_UPDATE_SPREADSHEET_PROPERTIES",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── outlook ─────────────────────────────────────────────────────────
+pub const OUTLOOK_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "OUTLOOK_GET_MESSAGE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_LIST_MESSAGES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_SEARCH_MESSAGES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_LIST_CALENDARS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_LIST_CALENDAR_EVENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_GET_CALENDAR_EVENT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_LIST_CONTACTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_LIST_MAIL_FOLDERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_SEND_EMAIL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CREATE_DRAFT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_SEND_DRAFT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CREATE_DRAFT_REPLY",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CREATE_ME_FORWARD_DRAFT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CALENDAR_CREATE_EVENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CREATE_CONTACT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CREATE_MAIL_FOLDER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_DELETE_MESSAGE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_BATCH_MOVE_MESSAGES",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_BATCH_UPDATE_MESSAGES",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_ACCEPT_EVENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CANCEL_EVENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CREATE_ME_CALENDAR_PERMISSION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "OUTLOOK_CREATE_EMAIL_RULE",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── microsoft_teams ─────────────────────────────────────────────────
+pub const MICROSOFT_TEAMS_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_GET_CHAT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_GET_CHANNEL",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_GET_TEAM_FROM_GROUP",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_CHATS_GET_ALL_CHATS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_GET_PRESENCE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_GET_ONLINE_MEETING",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_GET_SCHEDULE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_CREATE_CHANNEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_CREATE_TEAM",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_CREATE_MEETING",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_ADD_TEAM_MEMBER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_ADD_CHAT_MEMBER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_CREATE_SHIFT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_CREATE_TIME_OFF_REQUEST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_DELETE_TEAM",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_DELETE_CHANNEL",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_ARCHIVE_TEAM",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_ARCHIVE_CHANNEL",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_DELETE_TAB",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "MICROSOFT_TEAMS_DELETE_TIME_OFF",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── linear ──────────────────────────────────────────────────────────
+pub const LINEAR_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "LINEAR_LIST_LINEAR_ISSUES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_GET_LINEAR_ISSUE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_LIST_LINEAR_TEAMS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_LIST_LINEAR_PROJECTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_LIST_LINEAR_STATES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_SEARCH_ISSUES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_GET_CYCLES_BY_TEAM_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_LIST_LINEAR_USERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_LIST_LINEAR_LABELS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_GET_LINEAR_PROJECT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "LINEAR_CREATE_LINEAR_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_UPDATE_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_CREATE_LINEAR_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_CREATE_ATTACHMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_CREATE_LINEAR_PROJECT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_CREATE_LINEAR_LABEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_UPDATE_LINEAR_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_CREATE_ISSUE_RELATION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_UPDATE_LINEAR_PROJECT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "LINEAR_DELETE_LINEAR_ISSUE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "LINEAR_REMOVE_ISSUE_LABEL",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── jira ────────────────────────────────────────────────────────────
+pub const JIRA_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "JIRA_GET_ISSUE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_GET_ALL_PROJECTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_FETCH_BULK_ISSUES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_GET_ISSUE_TYPES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_GET_PROJECT_ROLES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_FIND_USERS2",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_GET_FIELDS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_GET_ISSUE_EDIT_METADATA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_GET_PROJECT_VERSIONS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "JIRA_CREATE_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_BULK_CREATE_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_EDIT_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_ADD_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_ASSIGN_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_ADD_ATTACHMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_CREATE_ISSUE_LINK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_ADD_WORKLOG",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_TRANSITION_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "JIRA_DELETE_ISSUE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "JIRA_DELETE_COMMENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "JIRA_DELETE_VERSION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "JIRA_DELETE_WORKLOG",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "JIRA_CREATE_PROJECT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "JIRA_ADD_USERS_TO_PROJECT_ROLE",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── trello ──────────────────────────────────────────────────────────
+pub const TRELLO_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "TRELLO_GET_BOARDS_BY_ID_BOARD",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TRELLO_GET_ACTIONS_BY_ID_ACTION",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TRELLO_GET_BATCH",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TRELLO_GET_BOARDS_ACTIONS_BY_ID_BOARD",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TRELLO_GET_MEMBERS_BOARDS_BY_ID_MEMBER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TRELLO_ADD_CARDS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_ADD_BOARDS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_ADD_LISTS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_ADD_CARDS_ACTIONS_COMMENTS_BY_ID_CARD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_ADD_MEMBER_TO_CARD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_CREATE_CARD_LABEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_ADD_CARDS_ATTACHMENTS_BY_ID_CARD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_ADD_CARDS_CHECKLISTS_BY_ID_CARD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_CREATE_WEBHOOK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TRELLO_DELETE_CARDS_BY_ID_CARD",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TRELLO_DELETE_BOARD",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TRELLO_DELETE_CHECKLISTS_BY_ID_CHECKLIST",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TRELLO_ARCHIVE_ALL_LIST_CARDS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TRELLO_DELETE_CARD_COMMENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TRELLO_DELETE_LABELS_BY_ID_LABEL",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TRELLO_DELETE_ORGANIZATIONS_BY_ID_ORG",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TRELLO_DELETE_WEBHOOKS_BY_ID_WEBHOOK",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── asana ───────────────────────────────────────────────────────────
+pub const ASANA_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "ASANA_GET_A_TASK",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_A_PROJECT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_MULTIPLE_TASKS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_MULTIPLE_PROJECTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_CURRENT_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_MULTIPLE_WORKSPACES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_PORTFOLIO",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_GOALS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_GET_CUSTOM_FIELDS_FOR_WORKSPACE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "ASANA_CREATE_A_TASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_CREATE_A_PROJECT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_CREATE_SUBTASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_CREATE_TASK_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_UPDATE_A_TASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_ADD_FOLLOWERS_TO_TASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_ADD_TAG_TO_TASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_ADD_PROJECT_FOR_TASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_ADD_TASK_DEPENDENCIES",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_CREATE_ATTACHMENT_FOR_TASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "ASANA_DELETE_TASK",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "ASANA_DELETE_PROJECT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "ASANA_DELETE_SECTION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "ASANA_DELETE_TAG",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "ASANA_DELETE_CUSTOM_FIELD",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "ASANA_DELETE_MEMBERSHIP",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── dropbox ─────────────────────────────────────────────────────────
+pub const DROPBOX_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "DROPBOX_GET_METADATA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DROPBOX_FILES_SEARCH",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DROPBOX_LIST_FILE_MEMBERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DROPBOX_GET_SHARED_LINK_METADATA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DROPBOX_GET_ABOUT_ME",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DROPBOX_GET_SPACE_USAGE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "DROPBOX_ALPHA_UPLOAD_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "DROPBOX_CREATE_FOLDER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "DROPBOX_COPY_FILE_OR_FOLDER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "DROPBOX_CREATE_SHARED_LINK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "DROPBOX_ADD_FILE_MEMBER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "DROPBOX_DELETE_FILE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "DROPBOX_DELETE_BATCH",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "DROPBOX_ADD_TEAM_MEMBERS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "DROPBOX_CREATE_TEAM_FOLDER",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "DROPBOX_ARCHIVE_TEAM_FOLDER",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── twitter ─────────────────────────────────────────────────────────
+pub const TWITTER_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "TWITTER_RECENT_SEARCH",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_GET_USER_BY_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_POST_LOOKUP_BY_POST_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_FOLLOWERS_BY_USER_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_FOLLOWING_BY_USER_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_BOOKMARKS_BY_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_GET_LIST_MEMBERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_FULL_ARCHIVE_SEARCH",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TWITTER_CREATION_OF_A_POST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_RETWEET_POST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_ADD_POST_TO_BOOKMARKS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_FOLLOW_USER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_MUTE_USER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_CREATE_DM_CONVERSATION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_CREATE_LIST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_ADD_LIST_MEMBER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TWITTER_POST_DELETE_BY_POST_ID",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TWITTER_DELETE_LIST",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TWITTER_REMOVE_LIST_MEMBER",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TWITTER_DELETE_DM",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TWITTER_REMOVE_POST_FROM_BOOKMARKS",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── spotify ─────────────────────────────────────────────────────────
+pub const SPOTIFY_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "SPOTIFY_GET_CURRENT_USER_S_PROFILE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_GET_USER_S_TOP_TRACKS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_GET_PLAYLIST",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_GET_PLAYLIST_ITEMS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_GET_RECENTLY_PLAYED_TRACKS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_GET_USER_S_SAVED_TRACKS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_SEARCH_FOR_ITEM",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_GET_AVAILABLE_DEVICES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_ADD_ITEMS_TO_PLAYLIST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_CREATE_PLAYLIST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_SAVE_TRACKS_FOR_CURRENT_USER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_PAUSE_PLAYBACK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_ADD_ITEM_TO_PLAYBACK_QUEUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_CHANGE_PLAYLIST_DETAILS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_REMOVE_PLAYLIST_ITEMS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_REMOVE_USER_S_SAVED_TRACKS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_UNFOLLOW_ARTISTS_OR_USERS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SPOTIFY_REMOVE_USER_S_SAVED_ALBUMS",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── telegram ────────────────────────────────────────────────────────
+pub const TELEGRAM_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "TELEGRAM_GET_UPDATES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_GET_CHAT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_GET_CHAT_HISTORY",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_GET_CHAT_MEMBER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_GET_CHAT_MEMBERS_COUNT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_GET_CHAT_ADMINISTRATORS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_GET_ME",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_SEND_MESSAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_SEND_PHOTO",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_SEND_DOCUMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_SEND_LOCATION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_SEND_POLL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_FORWARD_MESSAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_EDIT_MESSAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_ANSWER_CALLBACK_QUERY",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_DELETE_MESSAGE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_CREATE_CHAT_INVITE_LINK",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "TELEGRAM_SET_MY_COMMANDS",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── whatsapp ────────────────────────────────────────────────────────
+pub const WHATSAPP_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "WHATSAPP_GET_PHONE_NUMBERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_GET_MESSAGE_TEMPLATES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_GET_PHONE_NUMBER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_GET_BUSINESS_PROFILE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_GET_TEMPLATE_STATUS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_GET_MEDIA_INFO",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_SEND_MESSAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_SEND_TEMPLATE_MESSAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_SEND_MEDIA",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_SEND_MEDIA_BY_ID",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_SEND_INTERACTIVE_BUTTONS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_SEND_INTERACTIVE_LIST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_UPLOAD_MEDIA",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_CREATE_MESSAGE_TEMPLATE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "WHATSAPP_DELETE_MESSAGE_TEMPLATE",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── shopify ─────────────────────────────────────────────────────────
+pub const SHOPIFY_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "SHOPIFY_BULK_QUERY_OPERATION",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_COUNT_PRODUCTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_COUNT_ORDERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_COUNT_FULFILLMENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_COUNT_CUSTOMERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_ORDER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_PRODUCT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_DRAFT_ORDER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_FULFILLMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_CUSTOMER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_PRICE_RULE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_ADJUST_INVENTORY_LEVEL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_DISCOUNT_CODE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_UPDATE_PRODUCT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CREATE_CUSTOM_COLLECTION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CANCEL_ORDER",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_CANCEL_FULFILLMENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_DELETE_PRODUCT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_BULK_DELETE_CUSTOMER_ADDRESSES",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SHOPIFY_BULK_DELETE_METAFIELDS",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── stripe ──────────────────────────────────────────────────────────
+pub const STRIPE_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "STRIPE_GET_PAYMENT_INTENT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "STRIPE_LIST_INVOICES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "STRIPE_GET_CUSTOMER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "STRIPE_LIST_CHARGES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "STRIPE_GET_SUBSCRIPTION",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "STRIPE_CREATE_PAYMENT_INTENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_CREATE_INVOICE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_CREATE_CUSTOMER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_CREATE_CUSTOMER_SUBSCRIPTION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_CREATE_CHECKOUT_SESSION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_CONFIRM_PAYMENT_INTENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_CAPTURE_PAYMENT_INTENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_ATTACH_PAYMENT_METHOD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "STRIPE_CANCEL_SUBSCRIPTION",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "STRIPE_CANCEL_PAYMENT_INTENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "STRIPE_CREATE_CHARGE_REFUND",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "STRIPE_CLOSE_DISPUTE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "STRIPE_CANCEL_SETUP_INTENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "STRIPE_ARCHIVE_BILLING_ALERT",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── hubspot ─────────────────────────────────────────────────────────
+pub const HUBSPOT_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "HUBSPOT_GET_CONTACTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_SEARCH_CONTACTS_BY_CRITERIA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_LIST_CONTACTS_PAGE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_GET_COMPANIES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_GET_DEALS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_GET_CRM_OBJECT_BY_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_BATCH_READ_COMPANIES_BY_PROPERTIES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_CREATE_CONTACT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_CREATE_COMPANY",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_CREATE_DEAL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_CREATE_CONTACTS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_UPDATE_CONTACT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_UPDATE_COMPANY",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_CREATE_OBJECT_ASSOCIATION",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_CREATE_A_NEW_MARKETING_EMAIL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_CREATE_BATCH_OF_OBJECTS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_BATCH_UPDATE_QUOTES",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_CONTACT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_COMPANY",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_DEAL",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_CONTACTS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_COMPANIES",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_DEALS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_CRM_OBJECT_BY_ID",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "HUBSPOT_ARCHIVE_PROPERTY_BY_OBJECT_TYPE_AND_NAME",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── salesforce ──────────────────────────────────────────────────────
+pub const SALESFORCE_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "SALESFORCE_RUN_SOQL_QUERY",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_EXECUTE_SOSL_SEARCH",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_GET_ACCOUNT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_GET_CAMPAIGN",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_GET_ALL_FIELDS_FOR_OBJECT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_GET_ALL_CUSTOM_OBJECTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CREATE_ACCOUNT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CREATE_CONTACT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CREATE_LEAD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CREATE_OPPORTUNITY",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CREATE_CAMPAIGN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CREATE_TASK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_UPDATE_ACCOUNT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_UPDATE_CONTACT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_UPDATE_OPPORTUNITY",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_ADD_OPPORTUNITY_LINE_ITEM",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_ADD_CONTACT_TO_CAMPAIGN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_ADD_LEAD_TO_CAMPAIGN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_ASSOCIATE_CONTACT_TO_ACCOUNT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CLONE_OPPORTUNITY_WITH_PRODUCTS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_DELETE_ACCOUNT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_DELETE_CONTACT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_DELETE_LEAD",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_DELETE_OPPORTUNITY",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_DELETE_CAMPAIGN",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_DELETE_SOBJECT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_DELETE_SOBJECT_COLLECTIONS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "SALESFORCE_CREATE_CUSTOM_FIELD",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── airtable ────────────────────────────────────────────────────────
+pub const AIRTABLE_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "AIRTABLE_LIST_RECORDS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_GET_RECORD",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_GET_BASE_SCHEMA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_LIST_BASES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_LIST_COMMENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_CREATE_RECORDS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_UPDATE_RECORD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_UPDATE_MULTIPLE_RECORDS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_CREATE_FIELD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_CREATE_TABLE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_CREATE_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_UPLOAD_ATTACHMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_UPDATE_FIELD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_UPDATE_TABLE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_DELETE_RECORD",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_DELETE_MULTIPLE_RECORDS",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_DELETE_COMMENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "AIRTABLE_CREATE_BASE",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── figma ───────────────────────────────────────────────────────────
+pub const FIGMA_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "FIGMA_GET_FILE_JSON",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_GET_FILE_NODES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_GET_COMMENTS_IN_A_FILE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_GET_CURRENT_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_DISCOVER_FIGMA_RESOURCES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_GET_FILE_COMPONENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_GET_LOCAL_VARIABLES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_EXTRACT_DESIGN_TOKENS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "FIGMA_ADD_A_COMMENT_TO_A_FILE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "FIGMA_CREATE_DEV_RESOURCES",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "FIGMA_CREATE_MODIFY_DELETE_VARIABLES",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "FIGMA_DELETE_A_COMMENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "FIGMA_DELETE_A_WEBHOOK",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "FIGMA_DELETE_DEV_RESOURCE",
+ scope: ToolScope::Admin,
+ },
+];
+
+// ── youtube ─────────────────────────────────────────────────────────
+pub const YOUTUBE_CURATED: &[CuratedTool] = &[
+ CuratedTool {
+ slug: "YOUTUBE_SEARCH_YOU_TUBE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_LIST_CHANNEL_VIDEOS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_GET_CHANNEL_STATISTICS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_LIST_COMMENT_THREADS2",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_LIST_COMMENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_GET_VIDEO_DETAILS_BATCH",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_LIST_USER_PLAYLISTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_LIST_PLAYLIST_ITEMS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_UPLOAD_VIDEO",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_UPDATE_VIDEO",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_CREATE_PLAYLIST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_ADD_VIDEO_TO_PLAYLIST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_POST_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_RATE_VIDEO",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_UPDATE_PLAYLIST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_DELETE_VIDEO",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_DELETE_PLAYLIST",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_DELETE_COMMENT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "YOUTUBE_DELETE_PLAYLIST_ITEM",
+ scope: ToolScope::Admin,
+ },
+];
diff --git a/src/openhuman/composio/providers/github/mod.rs b/src/openhuman/composio/providers/github/mod.rs
new file mode 100644
index 000000000..acee9477a
--- /dev/null
+++ b/src/openhuman/composio/providers/github/mod.rs
@@ -0,0 +1,11 @@
+//! GitHub Composio toolkit — curated tool catalog only.
+//!
+//! There is no native [`super::ComposioProvider`] implementation for
+//! GitHub yet (no profile fetch / sync). The curated catalog here is
+//! still consulted by [`super::catalog_for_toolkit`] so the meta-tool
+//! layer applies the same whitelist + scope filtering it does for
+//! Gmail and Notion.
+
+pub mod tools;
+
+pub use tools::GITHUB_CURATED;
diff --git a/src/openhuman/composio/providers/github/tools.rs b/src/openhuman/composio/providers/github/tools.rs
new file mode 100644
index 000000000..d22f7963c
--- /dev/null
+++ b/src/openhuman/composio/providers/github/tools.rs
@@ -0,0 +1,188 @@
+//! Curated catalog of GitHub Composio actions exposed to the agent.
+//!
+//! Composio publishes hundreds of GitHub actions; this hand-tuned slice
+//! covers the day-to-day operations an AI assistant actually performs
+//! (browsing repos, reading/writing issues + PRs, code search, basic
+//! workflow control) and hides the long tail of admin endpoints.
+
+use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
+
+pub const GITHUB_CURATED: &[CuratedTool] = &[
+ // ── Read: user / repos ──────────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_GET_AUTHENTICATED_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_LIST_REPOSITORIES_FOR_AUTHENTICATED_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_GET_A_REPOSITORY",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_LIST_REPOSITORY_COLLABORATORS",
+ scope: ToolScope::Read,
+ },
+ // ── Read: search ────────────────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_SEARCH_REPOSITORIES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_SEARCH_CODE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_SEARCH_ISSUES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_SEARCH_USERS",
+ scope: ToolScope::Read,
+ },
+ // ── Read: issues ────────────────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_LIST_REPOSITORY_ISSUES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_GET_AN_ISSUE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_LIST_ISSUE_COMMENTS",
+ scope: ToolScope::Read,
+ },
+ // ── Read: pull requests ─────────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_LIST_PULL_REQUESTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_GET_A_PULL_REQUEST",
+ scope: ToolScope::Read,
+ },
+ // CuratedTool { slug: "GITHUB_CHECK_IF_PULL_REQUEST_HAS_BEEN_MERGED", scope: ToolScope::Read },
+ // ── Read: branches / commits ────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_LIST_BRANCHES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_GET_A_BRANCH",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_LIST_COMMITS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GITHUB_GET_A_COMMIT",
+ scope: ToolScope::Read,
+ },
+ // CuratedTool { slug: "GITHUB_COMPARE_TWO_COMMITS", scope: ToolScope::Read },
+ // // ── Read: contents / releases / gists ───────────────────────────
+ // CuratedTool { slug: "GITHUB_GET_REPOSITORY_CONTENTS", scope: ToolScope::Read },
+ // CuratedTool { slug: "GITHUB_LIST_RELEASES", scope: ToolScope::Read },
+ // CuratedTool { slug: "GITHUB_LIST_GISTS", scope: ToolScope::Read },
+ // // ── Read: workflows ─────────────────────────────────────────────
+ // CuratedTool { slug: "GITHUB_LIST_WORKFLOWS", scope: ToolScope::Read },
+ // CuratedTool { slug: "GITHUB_LIST_WORKFLOW_RUNS", scope: ToolScope::Read },
+ // ── Write: repos / contents ─────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_CREATE_A_REPOSITORY_FOR_AUTHENTICATED_USER",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_CREATE_OR_UPDATE_FILE_CONTENTS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_CREATE_A_COMMIT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_COMMIT_MULTIPLE_FILES",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_CREATE_A_COMMIT_COMMENT",
+ scope: ToolScope::Write,
+ },
+ // ── Write: issues ───────────────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_CREATE_AN_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_UPDATE_AN_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_CLOSE_AN_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_CREATE_AN_ISSUE_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_ADD_LABELS_TO_AN_ISSUE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_ADD_ASSIGNEES_TO_AN_ISSUE",
+ scope: ToolScope::Write,
+ },
+ // ── Write: pull requests ────────────────────────────────────────
+ CuratedTool {
+ slug: "GITHUB_CREATE_A_PULL_REQUEST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_UPDATE_A_PULL_REQUEST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_MERGE_A_PULL_REQUEST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_CREATE_A_REVIEW_FOR_A_PULL_REQUEST",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GITHUB_CREATE_A_REVIEW_COMMENT_FOR_A_PULL_REQUEST",
+ scope: ToolScope::Write,
+ },
+ // // ── Write: releases / gists / workflows ─────────────────────────
+ // CuratedTool { slug: "GITHUB_CREATE_A_RELEASE", scope: ToolScope::Write },
+ CuratedTool {
+ slug: "GITHUB_CREATE_A_GIST",
+ scope: ToolScope::Write,
+ },
+ // CuratedTool { slug: "GITHUB_CREATE_WORKFLOW_DISPATCH", scope: ToolScope::Write },
+ // ── Admin: destructive / permission-changing ────────────────────
+ CuratedTool {
+ slug: "GITHUB_DELETE_A_REPOSITORY",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GITHUB_DELETE_A_BRANCH",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GITHUB_DELETE_A_FILE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GITHUB_ADD_A_REPOSITORY_COLLABORATOR",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GITHUB_CANCEL_WORKFLOW_RUN",
+ scope: ToolScope::Admin,
+ },
+];
diff --git a/src/openhuman/composio/providers/gmail/mod.rs b/src/openhuman/composio/providers/gmail/mod.rs
index 23f03dac2..1fc96a33f 100644
--- a/src/openhuman/composio/providers/gmail/mod.rs
+++ b/src/openhuman/composio/providers/gmail/mod.rs
@@ -2,5 +2,7 @@ mod provider;
mod sync;
#[cfg(test)]
mod tests;
+pub mod tools;
pub use provider::GmailProvider;
+pub use tools::GMAIL_CURATED;
diff --git a/src/openhuman/composio/providers/gmail/provider.rs b/src/openhuman/composio/providers/gmail/provider.rs
index 05c333a17..10c9aa588 100644
--- a/src/openhuman/composio/providers/gmail/provider.rs
+++ b/src/openhuman/composio/providers/gmail/provider.rs
@@ -25,7 +25,8 @@ use crate::openhuman::composio::providers::sync_state::{
extract_item_id, persist_single_item, SyncState,
};
use crate::openhuman::composio::providers::{
- pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason,
+ pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
+ SyncReason,
};
const ACTION_GET_PROFILE: &str = "GMAIL_GET_PROFILE";
@@ -79,6 +80,10 @@ impl ComposioProvider for GmailProvider {
"gmail"
}
+ fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
+ Some(super::tools::GMAIL_CURATED)
+ }
+
fn sync_interval_secs(&self) -> Option {
Some(15 * 60)
}
diff --git a/src/openhuman/composio/providers/gmail/tools.rs b/src/openhuman/composio/providers/gmail/tools.rs
new file mode 100644
index 000000000..3f64ad929
--- /dev/null
+++ b/src/openhuman/composio/providers/gmail/tools.rs
@@ -0,0 +1,145 @@
+//! Curated catalog of Gmail Composio actions exposed to the agent.
+//!
+//! Composio publishes 60+ Gmail actions; this hand-tuned slice covers
+//! the cases the agent actually plans for (read, compose, manage) and
+//! hides the long tail of edge-case admin endpoints.
+
+use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
+
+pub const GMAIL_CURATED: &[CuratedTool] = &[
+ // ── Read: messages & threads ────────────────────────────────────
+ CuratedTool {
+ slug: "GMAIL_FETCH_EMAILS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_LIST_MESSAGES",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_FETCH_MESSAGE_BY_THREAD_ID",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_LIST_THREADS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_GET_ATTACHMENT",
+ scope: ToolScope::Read,
+ },
+ // ── Read: profile & settings ────────────────────────────────────
+ CuratedTool {
+ slug: "GMAIL_GET_PROFILE",
+ scope: ToolScope::Read,
+ },
+ // CuratedTool { slug: "GMAIL_GET_LANGUAGE_SETTINGS", scope: ToolScope::Read },
+ // CuratedTool { slug: "GMAIL_GET_VACATION_SETTINGS", scope: ToolScope::Read },
+ // CuratedTool { slug: "GMAIL_GET_AUTO_FORWARDING", scope: ToolScope::Read },
+ // ── Read: contacts & people ─────────────────────────────────────
+ CuratedTool {
+ slug: "GMAIL_GET_CONTACTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_GET_PEOPLE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_SEARCH_PEOPLE",
+ scope: ToolScope::Read,
+ },
+ // ── Read: drafts & labels ───────────────────────────────────────
+ CuratedTool {
+ slug: "GMAIL_LIST_DRAFTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_GET_DRAFT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_LIST_LABELS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "GMAIL_GET_LABEL",
+ scope: ToolScope::Read,
+ },
+ // ── Write: send & compose ───────────────────────────────────────
+ CuratedTool {
+ slug: "GMAIL_SEND_EMAIL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GMAIL_REPLY_TO_THREAD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GMAIL_FORWARD_MESSAGE",
+ scope: ToolScope::Write,
+ },
+ // ── Write: drafts ───────────────────────────────────────────────
+ CuratedTool {
+ slug: "GMAIL_CREATE_EMAIL_DRAFT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GMAIL_UPDATE_DRAFT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "GMAIL_SEND_DRAFT",
+ scope: ToolScope::Write,
+ },
+ // ── Write: labels (create/update on user labels) ────────────────
+ // CuratedTool { slug: "GMAIL_CREATE_LABEL", scope: ToolScope::Write },
+ // CuratedTool { slug: "GMAIL_UPDATE_LABEL", scope: ToolScope::Write },
+ // CuratedTool { slug: "GMAIL_PATCH_LABEL", scope: ToolScope::Write },
+ CuratedTool {
+ slug: "GMAIL_ADD_LABEL_TO_EMAIL",
+ scope: ToolScope::Write,
+ },
+ // ── Admin: destructive & permission-changing ────────────────────
+ CuratedTool {
+ slug: "GMAIL_DELETE_MESSAGE",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GMAIL_BATCH_DELETE_MESSAGES",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GMAIL_MOVE_TO_TRASH",
+ scope: ToolScope::Admin,
+ },
+ // CuratedTool { slug: "GMAIL_UNTRASH_MESSAGE", scope: ToolScope::Admin },
+ CuratedTool {
+ slug: "GMAIL_DELETE_THREAD",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GMAIL_MOVE_THREAD_TO_TRASH",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GMAIL_UNTRASH_THREAD",
+ scope: ToolScope::Admin,
+ },
+ // CuratedTool { slug: "GMAIL_MODIFY_THREAD_LABELS", scope: ToolScope::Admin },
+ // CuratedTool { slug: "GMAIL_BATCH_MODIFY_MESSAGES", scope: ToolScope::Admin },
+ CuratedTool {
+ slug: "GMAIL_DELETE_DRAFT",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "GMAIL_DELETE_LABEL",
+ scope: ToolScope::Admin,
+ },
+ // CuratedTool { slug: "GMAIL_PATCH_SEND_AS", scope: ToolScope::Admin },
+ // CuratedTool { slug: "GMAIL_UPDATE_IMAP_SETTINGS", scope: ToolScope::Admin },
+];
diff --git a/src/openhuman/composio/providers/mod.rs b/src/openhuman/composio/providers/mod.rs
index 4bd5bacd4..7460a7e39 100644
--- a/src/openhuman/composio/providers/mod.rs
+++ b/src/openhuman/composio/providers/mod.rs
@@ -34,22 +34,105 @@
mod descriptions;
pub(crate) mod helpers;
+pub mod tool_scope;
mod traits;
mod types;
+pub mod user_scopes;
+pub mod catalogs;
+pub mod github;
pub mod gmail;
pub mod notion;
+pub mod post_process;
pub mod profile;
pub mod registry;
pub mod sync_state;
+/// Static toolkit → curated catalog map.
+///
+/// This is consulted by the meta-tool layer alongside any registered
+/// provider's [`ComposioProvider::curated_tools`]. It lets toolkits
+/// without a full native provider (e.g. `github`, which has no sync
+/// logic yet) still benefit from curated whitelisting.
+///
+/// Lookup key is the lowercased prefix returned by
+/// [`toolkit_from_slug`] applied to the action slug — e.g.
+/// `GOOGLECALENDAR_CREATE_EVENT` → `"googlecalendar"`. Multi-segment
+/// prefixes like `MICROSOFT_TEAMS_*` are matched via their first
+/// segment with an extra arm.
+/// Synchronous visibility check for a Composio action slug given a
+/// pre-loaded user scope preference.
+///
+/// Returns `true` if the action should appear in the agent's tool
+/// surface — i.e. it's in the toolkit's curated whitelist (or the
+/// toolkit has no curation) **and** the user's scope pref allows its
+/// classification. Falls back to [`classify_unknown`] for un-curated
+/// toolkits.
+///
+/// Use this when the user pref has already been loaded for the
+/// toolkit (typical inside a `for slug in toolkits {...}` loop where
+/// awaiting once per toolkit is cheaper than once per action).
+pub fn is_action_visible_with_pref(slug: &str, pref: &UserScopePref) -> bool {
+ let Some(toolkit) = toolkit_from_slug(slug) else {
+ return true;
+ };
+ let catalog = get_provider(&toolkit)
+ .and_then(|p| p.curated_tools())
+ .or_else(|| catalog_for_toolkit(&toolkit));
+ match catalog {
+ Some(catalog) => match find_curated(catalog, slug) {
+ Some(curated) => pref.allows(curated.scope),
+ None => false,
+ },
+ None => pref.allows(classify_unknown(slug)),
+ }
+}
+
+pub fn catalog_for_toolkit(toolkit: &str) -> Option<&'static [CuratedTool]> {
+ match toolkit.trim().to_ascii_lowercase().as_str() {
+ // Native providers
+ "gmail" => Some(gmail::GMAIL_CURATED),
+ "notion" => Some(notion::NOTION_CURATED),
+ "github" => Some(github::GITHUB_CURATED),
+ // Catalog-only toolkits
+ "slack" => Some(catalogs::SLACK_CURATED),
+ "discord" => Some(catalogs::DISCORD_CURATED),
+ "googlecalendar" | "google_calendar" => Some(catalogs::GOOGLECALENDAR_CURATED),
+ "googledrive" | "google_drive" => Some(catalogs::GOOGLEDRIVE_CURATED),
+ "googledocs" | "google_docs" => Some(catalogs::GOOGLEDOCS_CURATED),
+ "googlesheets" | "google_sheets" => Some(catalogs::GOOGLESHEETS_CURATED),
+ "outlook" => Some(catalogs::OUTLOOK_CURATED),
+ // MICROSOFT_TEAMS_* slugs extract to "microsoft" via toolkit_from_slug.
+ "microsoft" | "microsoft_teams" => Some(catalogs::MICROSOFT_TEAMS_CURATED),
+ "linear" => Some(catalogs::LINEAR_CURATED),
+ "jira" => Some(catalogs::JIRA_CURATED),
+ "trello" => Some(catalogs::TRELLO_CURATED),
+ "asana" => Some(catalogs::ASANA_CURATED),
+ "dropbox" => Some(catalogs::DROPBOX_CURATED),
+ "twitter" => Some(catalogs::TWITTER_CURATED),
+ "spotify" => Some(catalogs::SPOTIFY_CURATED),
+ "telegram" => Some(catalogs::TELEGRAM_CURATED),
+ "whatsapp" => Some(catalogs::WHATSAPP_CURATED),
+ "shopify" => Some(catalogs::SHOPIFY_CURATED),
+ "stripe" => Some(catalogs::STRIPE_CURATED),
+ "hubspot" => Some(catalogs::HUBSPOT_CURATED),
+ "salesforce" => Some(catalogs::SALESFORCE_CURATED),
+ "airtable" => Some(catalogs::AIRTABLE_CURATED),
+ "figma" => Some(catalogs::FIGMA_CURATED),
+ "youtube" => Some(catalogs::YOUTUBE_CURATED),
+ _ => None,
+ }
+}
+
pub use descriptions::toolkit_description;
pub(crate) use helpers::pick_str;
pub use registry::{
all_providers, get_provider, init_default_providers, register_provider, ProviderArc,
};
+pub use tool_scope::{classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope};
pub use traits::ComposioProvider;
pub use types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason};
+pub use user_scopes::{load_or_default as load_user_scope_or_default, UserScopePref};
#[cfg(test)]
mod tests {
diff --git a/src/openhuman/composio/providers/notion/mod.rs b/src/openhuman/composio/providers/notion/mod.rs
index 0a94909c0..427b6fb3f 100644
--- a/src/openhuman/composio/providers/notion/mod.rs
+++ b/src/openhuman/composio/providers/notion/mod.rs
@@ -2,5 +2,7 @@ mod provider;
mod sync;
#[cfg(test)]
mod tests;
+pub mod tools;
pub use provider::NotionProvider;
+pub use tools::NOTION_CURATED;
diff --git a/src/openhuman/composio/providers/notion/provider.rs b/src/openhuman/composio/providers/notion/provider.rs
index f11a7df5f..a545a49fe 100644
--- a/src/openhuman/composio/providers/notion/provider.rs
+++ b/src/openhuman/composio/providers/notion/provider.rs
@@ -23,7 +23,8 @@ use crate::openhuman::composio::providers::sync_state::{
extract_item_id, persist_single_item, SyncState,
};
use crate::openhuman::composio::providers::{
- pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason,
+ pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
+ SyncReason,
};
pub(crate) const ACTION_GET_ABOUT_ME: &str = "NOTION_GET_ABOUT_ME";
@@ -69,6 +70,10 @@ impl ComposioProvider for NotionProvider {
"notion"
}
+ fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
+ Some(super::tools::NOTION_CURATED)
+ }
+
fn sync_interval_secs(&self) -> Option {
Some(30 * 60)
}
diff --git a/src/openhuman/composio/providers/notion/tools.rs b/src/openhuman/composio/providers/notion/tools.rs
new file mode 100644
index 000000000..760d93e9a
--- /dev/null
+++ b/src/openhuman/composio/providers/notion/tools.rs
@@ -0,0 +1,196 @@
+//! Curated catalog of Notion Composio actions exposed to the agent.
+
+use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
+
+pub const NOTION_CURATED: &[CuratedTool] = &[
+ // ── Read: search & fetch ────────────────────────────────────────
+ CuratedTool {
+ slug: "NOTION_SEARCH_NOTION_PAGE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_FETCH_DATA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_FETCH_DATABASE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_FETCH_ROW",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_FETCH_BLOCK_METADATA",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_FETCH_BLOCK_CONTENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_FETCH_ALL_BLOCK_CONTENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_FETCH_COMMENTS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_GET_PAGE_MARKDOWN",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_GET_PAGE_PROPERTY_ACTION",
+ scope: ToolScope::Read,
+ },
+ // ── Read: query & retrieve ──────────────────────────────────────
+ CuratedTool {
+ slug: "NOTION_QUERY_DATABASE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_QUERY_DATABASE_WITH_FILTER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_QUERY_DATA_SOURCE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_RETRIEVE_PAGE",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_RETRIEVE_COMMENT",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_RETRIEVE_DATABASE_PROPERTY",
+ scope: ToolScope::Read,
+ },
+ // ── Read: profile / users / files ───────────────────────────────
+ CuratedTool {
+ slug: "NOTION_LIST_USERS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_GET_ABOUT_USER",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_GET_ABOUT_ME",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_LIST_FILE_UPLOADS",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_RETRIEVE_FILE_UPLOAD",
+ scope: ToolScope::Read,
+ },
+ CuratedTool {
+ slug: "NOTION_LIST_DATA_SOURCE_TEMPLATES",
+ scope: ToolScope::Read,
+ },
+ // ── Write: create ───────────────────────────────────────────────
+ CuratedTool {
+ slug: "NOTION_CREATE_NOTION_PAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_CREATE_DATABASE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_CREATE_COMMENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_CREATE_FILE_UPLOAD",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_SEND_FILE_UPLOAD",
+ scope: ToolScope::Write,
+ },
+ // ── Write: update / append ──────────────────────────────────────
+ CuratedTool {
+ slug: "NOTION_UPDATE_PAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_UPDATE_BLOCK",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_UPDATE_ROW_DATABASE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_INSERT_ROW_DATABASE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_INSERT_ROW_FROM_NL",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_REPLACE_PAGE_CONTENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_ADD_PAGE_CONTENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_ADD_MULTIPLE_PAGE_CONTENT",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_APPEND_BLOCK_CHILDREN",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_APPEND_TEXT_BLOCKS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_APPEND_TASK_BLOCKS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_APPEND_CODE_BLOCKS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_APPEND_MEDIA_BLOCKS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_APPEND_LAYOUT_BLOCKS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_APPEND_TABLE_BLOCKS",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_DUPLICATE_PAGE",
+ scope: ToolScope::Write,
+ },
+ CuratedTool {
+ slug: "NOTION_MOVE_PAGE",
+ scope: ToolScope::Write,
+ },
+ // ── Admin: destructive ──────────────────────────────────────────
+ CuratedTool {
+ slug: "NOTION_DELETE_BLOCK",
+ scope: ToolScope::Admin,
+ },
+ CuratedTool {
+ slug: "NOTION_ARCHIVE_NOTION_PAGE",
+ scope: ToolScope::Admin,
+ },
+];
diff --git a/src/openhuman/composio/providers/post_process.rs b/src/openhuman/composio/providers/post_process.rs
new file mode 100644
index 000000000..e66cb5d2c
--- /dev/null
+++ b/src/openhuman/composio/providers/post_process.rs
@@ -0,0 +1,188 @@
+//! Per-toolkit post-processing of Composio action responses.
+//!
+//! Some upstream services return content in formats that are noisy for
+//! the agent's context window (e.g. Gmail's full HTML message body).
+//! This module gives each toolkit a chance to rewrite the response
+//! before it is handed back to the LLM — for instance converting HTML
+//! email bodies to markdown so the model spends fewer tokens parsing
+//! presentational markup.
+//!
+//! The dispatch is intentionally tiny: one function per toolkit that
+//! mutates a `serde_json::Value` in place. The Composio backend keeps
+//! evolving its response shapes so we walk values defensively rather
+//! than hard-coding field paths.
+
+use serde_json::Value;
+
+/// Apply toolkit-specific post-processing to an `composio_execute`
+/// response. Mutates `value` in place.
+///
+/// Calling this with an unknown toolkit slug is a no-op.
+pub fn post_process(toolkit: &str, _slug: &str, value: &mut Value) {
+ let key = toolkit.trim().to_ascii_lowercase();
+ match key.as_str() {
+ "gmail" => convert_html_strings(value, "gmail"),
+ _ => {}
+ }
+}
+
+/// Walk `value` recursively. Any string field whose contents look like
+/// HTML is replaced with its markdown rendering.
+///
+/// We use a substring heuristic instead of a full parse for speed —
+/// if the string contains both `<` and one of a few common email
+/// tags it's almost certainly HTML. False positives are harmless
+/// (the html2md output for a non-HTML string is essentially the
+/// stripped text).
+fn convert_html_strings(value: &mut Value, toolkit: &str) {
+ match value {
+ Value::String(s) => {
+ if looks_like_html(s) {
+ let md = html2md::parse_html(s);
+ tracing::debug!(
+ toolkit,
+ before_bytes = s.len(),
+ after_bytes = md.len(),
+ "[composio][post-process] html → markdown"
+ );
+ *s = md;
+ }
+ }
+ Value::Array(items) => {
+ for item in items {
+ convert_html_strings(item, toolkit);
+ }
+ }
+ Value::Object(map) => {
+ for (_, v) in map.iter_mut() {
+ convert_html_strings(v, toolkit);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Heuristic HTML detector. Returns `true` if the string contains an
+/// opening `<` followed (anywhere) by one of a handful of common tags
+/// found in email bodies. Tuned to avoid matching on stray angle
+/// brackets in plain-text quoted replies.
+fn looks_like_html(s: &str) -> bool {
+ if s.len() < 4 || !s.contains('<') {
+ return false;
+ }
+ // Cheap substring scan; case-insensitive via lowercased haystack.
+ // Bound the work for very large strings — we only need the first
+ // few KB to make the call. Walk back to a UTF-8 char boundary so we
+ // never slice in the middle of a multibyte sequence (4096 may land
+ // mid-codepoint).
+ let head = if s.len() > 4096 {
+ let mut end = 4096;
+ while end > 0 && !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ &s[..end]
+ } else {
+ s
+ };
+ let lower = head.to_ascii_lowercase();
+ const MARKERS: &[&str] = &[
+ "",
+ "",
+ "
",
+ " ",
+ "",
+ "Hi"));
+ assert!(looks_like_html(" Hello world
"));
+ assert!(looks_like_html("Mixed Case
"));
+ assert!(looks_like_html(" link "));
+ }
+
+ #[test]
+ fn looks_like_html_does_not_panic_on_multibyte_at_boundary() {
+ // Build a string > 4096 bytes whose 4096th byte lands inside a
+ // 3-byte UTF-8 codepoint. Naive `&s[..4096]` slicing panics here.
+ let mut s = String::with_capacity(4200);
+ // 4095 ASCII bytes, then a 3-byte CJK char straddling 4095..4098.
+ s.push_str(&"a".repeat(4095));
+ s.push('日');
+ s.push_str(&"b".repeat(100));
+ // Should not panic; result doesn't matter, only that it returns.
+ let _ = looks_like_html(&s);
+ }
+
+ #[test]
+ fn looks_like_html_rejects_plain_text() {
+ assert!(!looks_like_html("just a normal email body"));
+ assert!(!looks_like_html(""));
+ assert!(!looks_like_html("a < b but no tags"));
+ assert!(!looks_like_html("<<>>")); // not a real tag
+ }
+
+ #[test]
+ fn convert_walks_nested_arrays_and_objects() {
+ let mut v = json!({
+ "messages": [
+ { "id": "m1", "messageText": "
hello world
" },
+ { "id": "m2", "messageText": "plain text only" }
+ ],
+ "nextPageToken": null
+ });
+ convert_html_strings(&mut v, "gmail");
+ let m1 = v["messages"][0]["messageText"].as_str().unwrap();
+ // html2md emits at least the text content.
+ assert!(m1.contains("hello"));
+ assert!(m1.contains("world"));
+ assert!(!m1.contains("
"));
+ // Plain text untouched.
+ assert_eq!(v["messages"][1]["messageText"], "plain text only");
+ }
+
+ #[test]
+ fn post_process_unknown_toolkit_is_noop() {
+ let mut v = json!({ "body": "
hi
" });
+ let original = v.clone();
+ post_process("notion", "NOTION_FETCH_DATA", &mut v);
+ assert_eq!(v, original);
+ }
+
+ #[test]
+ fn post_process_gmail_converts_html_fields() {
+ let mut v = json!({
+ "data": { "html": "
Hi body
", "subject": "no tags here" }
+ });
+ post_process("gmail", "GMAIL_FETCH_EMAILS", &mut v);
+ let html_field = v["data"]["html"].as_str().unwrap();
+ assert!(!html_field.contains("
"));
+ assert!(html_field.contains("Hi"));
+ assert_eq!(v["data"]["subject"], "no tags here");
+ }
+}
diff --git a/src/openhuman/composio/providers/tool_scope.rs b/src/openhuman/composio/providers/tool_scope.rs
new file mode 100644
index 000000000..558641fdc
--- /dev/null
+++ b/src/openhuman/composio/providers/tool_scope.rs
@@ -0,0 +1,185 @@
+//! Per-action scope classification (read / write / admin) plus the
+//! [`CuratedTool`] catalog type that providers use to whitelist the
+//! actions they want surfaced to the agent.
+//!
+//! Composio publishes 60+ actions per toolkit; most are noise for the
+//! agent's planning loop. Each provider exports a hand-curated
+//! [`CuratedTool`] slice via [`super::ComposioProvider::curated_tools`]
+//! that pares the surface down to a useful subset and tags every action
+//! with a [`ToolScope`] so per-user scope preferences can gate execution.
+
+use serde::{Deserialize, Serialize};
+
+/// Classification of how invasive an action is.
+///
+/// Used both to filter the agent's visible tool list and to enforce
+/// per-user scope preferences at execution time.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ToolScope {
+ /// Pure reads — `GET` / `FETCH` / `LIST` / `SEARCH` / `GET_PROFILE`.
+ Read,
+ /// Side-effectful actions that create or mutate user data —
+ /// `SEND` / `CREATE` / `UPDATE` / `REPLY` / `APPEND`.
+ Write,
+ /// Destructive or permission-changing actions — `DELETE` / `TRASH` /
+ /// `REMOVE` / `MODIFY_LABELS` / `SHARE`.
+ Admin,
+}
+
+impl ToolScope {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ ToolScope::Read => "read",
+ ToolScope::Write => "write",
+ ToolScope::Admin => "admin",
+ }
+ }
+}
+
+/// One curated entry in a provider's tool catalog.
+///
+/// `slug` is the Composio action slug as returned by `composio_list_tools`
+/// (e.g. `"GMAIL_SEND_EMAIL"`). `scope` controls whether the action is
+/// gated by the user's read / write / admin preference.
+#[derive(Debug, Clone, Copy)]
+pub struct CuratedTool {
+ pub slug: &'static str,
+ pub scope: ToolScope,
+}
+
+/// Heuristic fallback when we need to gate a tool that isn't in any
+/// provider's curated list. Prefer the curated classification when
+/// available; only call this when [`super::ComposioProvider::curated_tools`]
+/// returned `None` or didn't include the slug.
+pub fn classify_unknown(slug: &str) -> ToolScope {
+ let upper = slug.to_ascii_uppercase();
+ // Admin verbs are checked first so e.g. `MODIFY_LABELS` doesn't slip
+ // into the Write bucket on the `UPDATE`-substring rule.
+ const ADMIN: &[&str] = &[
+ "DELETE",
+ "TRASH",
+ "REMOVE",
+ "MODIFY_LABELS",
+ "SHARE",
+ "REVOKE",
+ "DESTROY",
+ ];
+ const WRITE: &[&str] = &[
+ "SEND", "CREATE", "UPDATE", "REPLY", "APPEND", "INSERT", "ADD", "POST", "PATCH", "WRITE",
+ "DRAFT",
+ ];
+ if ADMIN.iter().any(|kw| upper.contains(kw)) {
+ return ToolScope::Admin;
+ }
+ if WRITE.iter().any(|kw| upper.contains(kw)) {
+ return ToolScope::Write;
+ }
+ ToolScope::Read
+}
+
+/// Look up a slug inside a curated catalog.
+pub fn find_curated<'a>(catalog: &'a [CuratedTool], slug: &str) -> Option<&'a CuratedTool> {
+ catalog.iter().find(|t| t.slug.eq_ignore_ascii_case(slug))
+}
+
+/// Extract the toolkit slug from a Composio action slug.
+///
+/// All Composio action slugs follow the convention `__…`
+/// (e.g. `GMAIL_SEND_EMAIL` → `gmail`). Returns the lowercased prefix
+/// before the first underscore, or `None` if the slug has no underscore.
+///
+/// **Assumption:** toolkit identifiers themselves do not contain
+/// underscores. Composio honours this for every action we curate today
+/// (`gmail`, `notion`, `googlecalendar`, …). The one historical
+/// exception — `MICROSOFT_TEAMS_*` — extracts to `"microsoft"`, and
+/// [`super::catalog_for_toolkit`] handles the alias by mapping both
+/// `"microsoft"` and `"microsoft_teams"` to the same catalog.
+///
+/// If a future toolkit ships with a multi-word slug containing an
+/// underscore in the *toolkit* portion (e.g. a hypothetical
+/// `FOO_BAR_LIST_ITEMS` whose toolkit is `foo_bar`), this naive split
+/// must be revised — either by consulting a known-toolkits map or by
+/// taking the longest-matching prefix from the registered catalogs.
+pub fn toolkit_from_slug(slug: &str) -> Option {
+ let trimmed = slug.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ let prefix = trimmed.split('_').next()?;
+ if prefix.is_empty() {
+ None
+ } else {
+ Some(prefix.to_ascii_lowercase())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn classify_unknown_picks_admin_for_destructive_verbs() {
+ assert_eq!(classify_unknown("GMAIL_DELETE_EMAIL"), ToolScope::Admin);
+ assert_eq!(classify_unknown("GMAIL_TRASH_EMAIL"), ToolScope::Admin);
+ assert_eq!(classify_unknown("GMAIL_MODIFY_LABELS"), ToolScope::Admin);
+ }
+
+ #[test]
+ fn classify_unknown_picks_write_for_mutating_verbs() {
+ assert_eq!(classify_unknown("GMAIL_SEND_EMAIL"), ToolScope::Write);
+ assert_eq!(classify_unknown("NOTION_CREATE_PAGE"), ToolScope::Write);
+ assert_eq!(classify_unknown("NOTION_UPDATE_PAGE"), ToolScope::Write);
+ }
+
+ #[test]
+ fn classify_unknown_defaults_to_read() {
+ assert_eq!(classify_unknown("GMAIL_FETCH_EMAILS"), ToolScope::Read);
+ assert_eq!(classify_unknown("NOTION_SEARCH"), ToolScope::Read);
+ assert_eq!(classify_unknown("GMAIL_GET_PROFILE"), ToolScope::Read);
+ }
+
+ #[test]
+ fn classify_unknown_admin_takes_precedence_over_write() {
+ // MODIFY_LABELS contains no write verb but DELETE_DRAFT does — make
+ // sure the admin check wins.
+ assert_eq!(classify_unknown("GMAIL_DELETE_DRAFT"), ToolScope::Admin);
+ }
+
+ #[test]
+ fn toolkit_from_slug_extracts_lowercase_prefix() {
+ assert_eq!(
+ toolkit_from_slug("GMAIL_SEND_EMAIL"),
+ Some("gmail".to_string())
+ );
+ assert_eq!(
+ toolkit_from_slug("NOTION_FETCH_DATA"),
+ Some("notion".to_string())
+ );
+ assert_eq!(toolkit_from_slug(""), None);
+ assert_eq!(
+ toolkit_from_slug("noUnderscore"),
+ Some("nounderscore".into())
+ );
+ }
+
+ #[test]
+ fn find_curated_is_case_insensitive() {
+ let catalog = &[CuratedTool {
+ slug: "GMAIL_SEND_EMAIL",
+ scope: ToolScope::Write,
+ }];
+ assert!(find_curated(catalog, "gmail_send_email").is_some());
+ assert!(find_curated(catalog, "GMAIL_SEND_EMAIL").is_some());
+ assert!(find_curated(catalog, "GMAIL_DELETE_EMAIL").is_none());
+ }
+
+ #[test]
+ fn tool_scope_serializes_lowercase() {
+ assert_eq!(serde_json::to_string(&ToolScope::Read).unwrap(), "\"read\"");
+ assert_eq!(
+ serde_json::to_string(&ToolScope::Admin).unwrap(),
+ "\"admin\""
+ );
+ }
+}
diff --git a/src/openhuman/composio/providers/traits.rs b/src/openhuman/composio/providers/traits.rs
index c1aec19e4..f053380e8 100644
--- a/src/openhuman/composio/providers/traits.rs
+++ b/src/openhuman/composio/providers/traits.rs
@@ -2,6 +2,7 @@
use async_trait::async_trait;
+use super::tool_scope::CuratedTool;
use super::types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason};
/// Native provider implementation for a specific Composio toolkit.
@@ -22,6 +23,22 @@ pub trait ComposioProvider: Send + Sync {
Some(15 * 60)
}
+ /// Curated whitelist of Composio actions this provider considers
+ /// useful for the agent, classified by [`super::tool_scope::ToolScope`].
+ ///
+ /// When `Some(&[...])`, the meta-tool layer hides every action not
+ /// in this list from `composio_list_tools` and rejects execution of
+ /// any slug not in this list (or whose scope is disabled in the
+ /// user's pref).
+ ///
+ /// Default: `None` — toolkits without a curated catalog (e.g.
+ /// integrations not yet hand-tuned) pass through all actions and
+ /// rely on the [`super::tool_scope::classify_unknown`] heuristic for
+ /// scope gating.
+ fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
+ None
+ }
+
/// Fetch a normalized user profile for the current connection in
/// `ctx`. Most providers implement this by calling a provider
/// "get profile / about me" action via [`super::super::ops::composio_execute`].
diff --git a/src/openhuman/composio/providers/user_scopes.rs b/src/openhuman/composio/providers/user_scopes.rs
new file mode 100644
index 000000000..f17ea5ddb
--- /dev/null
+++ b/src/openhuman/composio/providers/user_scopes.rs
@@ -0,0 +1,201 @@
+//! Per-user, per-toolkit scope preferences.
+//!
+//! For each Composio toolkit a user has connected (or could connect),
+//! we store a [`UserScopePref`] that records whether the agent is
+//! allowed to call **read**, **write**, and / or **admin**-classified
+//! actions for that toolkit. Defaults are `read=true, write=true,
+//! admin=false` — the agent can use the integration productively out of
+//! the box, but destructive / permission-changing actions require
+//! explicit opt-in.
+//!
+//! Storage uses the same KV surface as [`super::sync_state`]
+//! (`MemoryClient::kv_get` / `kv_set`) under a dedicated namespace so
+//! prefs survive process restarts without any extra file management.
+
+use serde::{Deserialize, Serialize};
+
+use crate::openhuman::memory::MemoryClientRef;
+
+use super::tool_scope::ToolScope;
+
+/// KV namespace for scope prefs. Separate from `composio-sync-state` so
+/// the two never collide.
+const KV_NAMESPACE: &str = "composio-user-scopes";
+
+/// Per-toolkit scope preference.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct UserScopePref {
+ #[serde(default = "default_true")]
+ pub read: bool,
+ #[serde(default = "default_true")]
+ pub write: bool,
+ #[serde(default)]
+ pub admin: bool,
+}
+
+fn default_true() -> bool {
+ true
+}
+
+impl Default for UserScopePref {
+ fn default() -> Self {
+ Self {
+ read: true,
+ write: true,
+ admin: false,
+ }
+ }
+}
+
+impl UserScopePref {
+ /// Returns `true` if the given scope is enabled in this preference.
+ pub fn allows(&self, scope: ToolScope) -> bool {
+ match scope {
+ ToolScope::Read => self.read,
+ ToolScope::Write => self.write,
+ ToolScope::Admin => self.admin,
+ }
+ }
+}
+
+fn kv_key(toolkit: &str) -> String {
+ toolkit.trim().to_ascii_lowercase()
+}
+
+/// Load the scope pref for `toolkit`. Returns the default
+/// (`read+write`, no `admin`) when nothing is stored or when the KV
+/// store can't be reached — the agent should always be able to use
+/// connected integrations productively, even if pref storage is
+/// temporarily unavailable.
+pub async fn load(memory: &MemoryClientRef, toolkit: &str) -> UserScopePref {
+ let key = kv_key(toolkit);
+ if key.is_empty() {
+ return UserScopePref::default();
+ }
+ match memory.kv_get(Some(KV_NAMESPACE), &key).await {
+ Ok(Some(value)) => match serde_json::from_value::(value) {
+ Ok(pref) => {
+ tracing::debug!(
+ toolkit = %key,
+ read = pref.read,
+ write = pref.write,
+ admin = pref.admin,
+ "[composio][scopes] pref loaded"
+ );
+ pref
+ }
+ Err(e) => {
+ tracing::warn!(
+ toolkit = %key,
+ error = %e,
+ "[composio][scopes] pref deserialize failed, falling back to default"
+ );
+ UserScopePref::default()
+ }
+ },
+ Ok(None) => {
+ tracing::debug!(
+ toolkit = %key,
+ "[composio][scopes] no pref stored, using default (read+write)"
+ );
+ UserScopePref::default()
+ }
+ Err(e) => {
+ tracing::warn!(
+ toolkit = %key,
+ error = %e,
+ "[composio][scopes] kv_get failed, falling back to default"
+ );
+ UserScopePref::default()
+ }
+ }
+}
+
+/// Persist a scope pref for `toolkit`.
+pub async fn save(
+ memory: &MemoryClientRef,
+ toolkit: &str,
+ pref: UserScopePref,
+) -> Result<(), String> {
+ let key = kv_key(toolkit);
+ if key.is_empty() {
+ return Err("user_scopes: toolkit must not be empty".to_string());
+ }
+ let value = serde_json::to_value(pref)
+ .map_err(|e| format!("[composio][scopes] serialize failed: {e}"))?;
+ memory.kv_set(Some(KV_NAMESPACE), &key, &value).await?;
+ tracing::info!(
+ toolkit = %key,
+ read = pref.read,
+ write = pref.write,
+ admin = pref.admin,
+ "[composio][scopes] pref saved"
+ );
+ Ok(())
+}
+
+/// Best-effort load that resolves the active memory client itself. Used
+/// from the meta-tool layer where we don't have a `MemoryClientRef` in
+/// scope. Falls back to the default pref when memory isn't initialised.
+pub async fn load_or_default(toolkit: &str) -> UserScopePref {
+ match crate::openhuman::memory::global::client_if_ready() {
+ Some(client) => load(&client, toolkit).await,
+ None => {
+ // Match the normalized key form `load()` logs so traces
+ // grouped by `key` correlate across both code paths.
+ let key = kv_key(toolkit);
+ tracing::debug!(
+ toolkit = %toolkit,
+ key = %key,
+ "[composio][scopes] memory not ready, using default pref"
+ );
+ UserScopePref::default()
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_is_read_write_no_admin() {
+ let p = UserScopePref::default();
+ assert!(p.read);
+ assert!(p.write);
+ assert!(!p.admin);
+ }
+
+ #[test]
+ fn allows_matches_scope() {
+ let p = UserScopePref {
+ read: true,
+ write: false,
+ admin: false,
+ };
+ assert!(p.allows(ToolScope::Read));
+ assert!(!p.allows(ToolScope::Write));
+ assert!(!p.allows(ToolScope::Admin));
+ }
+
+ #[test]
+ fn round_trip_serde() {
+ let p = UserScopePref {
+ read: true,
+ write: true,
+ admin: true,
+ };
+ let v = serde_json::to_value(p).unwrap();
+ let back: UserScopePref = serde_json::from_value(v).unwrap();
+ assert_eq!(p, back);
+ }
+
+ #[test]
+ fn missing_fields_default_to_true_for_read_write() {
+ // Forward-compat: if we ever drop a field, existing stored
+ // documents still deserialize sensibly.
+ let v = serde_json::json!({});
+ let p: UserScopePref = serde_json::from_value(v).unwrap();
+ assert_eq!(p, UserScopePref::default());
+ }
+}
diff --git a/src/openhuman/composio/schemas.rs b/src/openhuman/composio/schemas.rs
index be498e9a4..d137d1cea 100644
--- a/src/openhuman/composio/schemas.rs
+++ b/src/openhuman/composio/schemas.rs
@@ -51,6 +51,8 @@ pub fn all_controller_schemas() -> Vec {
schemas("get_user_profile"),
schemas("sync"),
schemas("list_trigger_history"),
+ schemas("get_user_scopes"),
+ schemas("set_user_scopes"),
]
}
@@ -100,6 +102,14 @@ pub fn all_registered_controllers() -> Vec {
schema: schemas("list_trigger_history"),
handler: handle_list_trigger_history,
},
+ RegisteredController {
+ schema: schemas("get_user_scopes"),
+ handler: handle_get_user_scopes,
+ },
+ RegisteredController {
+ schema: schemas("set_user_scopes"),
+ handler: handle_set_user_scopes,
+ },
]
}
@@ -334,6 +344,65 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
+ "get_user_scopes" => ControllerSchema {
+ namespace: "composio",
+ function: "get_user_scopes",
+ description:
+ "Read the per-toolkit user scope preference (read/write/admin) used to gate \
+ composio_execute. Defaults to {read:true, write:true, admin:false} when no \
+ pref is stored.",
+ inputs: vec![FieldSchema {
+ name: "toolkit",
+ ty: TypeSchema::String,
+ comment: "Toolkit slug, e.g. 'gmail' or 'notion'.",
+ required: true,
+ }],
+ outputs: vec![FieldSchema {
+ name: "pref",
+ ty: TypeSchema::Json,
+ comment: "Scope pref: { read: bool, write: bool, admin: bool }.",
+ required: true,
+ }],
+ },
+ "set_user_scopes" => ControllerSchema {
+ namespace: "composio",
+ function: "set_user_scopes",
+ description:
+ "Persist a per-toolkit user scope preference. The agent will only be able to \
+ invoke composio actions whose classified scope is enabled here.",
+ inputs: vec![
+ FieldSchema {
+ name: "toolkit",
+ ty: TypeSchema::String,
+ comment: "Toolkit slug, e.g. 'gmail' or 'notion'.",
+ required: true,
+ },
+ FieldSchema {
+ name: "read",
+ ty: TypeSchema::Bool,
+ comment: "Allow read-classified actions (GET / FETCH / LIST / SEARCH).",
+ required: true,
+ },
+ FieldSchema {
+ name: "write",
+ ty: TypeSchema::Bool,
+ comment: "Allow write-classified actions (SEND / CREATE / UPDATE).",
+ required: true,
+ },
+ FieldSchema {
+ name: "admin",
+ ty: TypeSchema::Bool,
+ comment: "Allow admin-classified actions (DELETE / TRASH / SHARE).",
+ required: true,
+ },
+ ],
+ outputs: vec![FieldSchema {
+ name: "pref",
+ ty: TypeSchema::Json,
+ comment: "Stored scope pref: { read, write, admin }.",
+ required: true,
+ }],
+ },
_other => ControllerSchema {
namespace: "composio",
function: "unknown",
@@ -459,6 +528,94 @@ fn handle_sync(params: Map) -> ControllerFuture {
})
}
+fn handle_get_user_scopes(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let toolkit = match read_required_non_empty(¶ms, "toolkit") {
+ Ok(v) => v,
+ Err(e) => {
+ tracing::warn!(
+ method = "composio.get_user_scopes",
+ error = %e,
+ "[composio:scopes] missing required `toolkit` param"
+ );
+ return Err(e);
+ }
+ };
+ tracing::debug!(
+ method = "composio.get_user_scopes",
+ toolkit = %toolkit,
+ "[composio:scopes] handler entry"
+ );
+ let pref = super::providers::user_scopes::load_or_default(&toolkit).await;
+ tracing::debug!(
+ method = "composio.get_user_scopes",
+ toolkit = %toolkit,
+ read = pref.read,
+ write = pref.write,
+ admin = pref.admin,
+ "[composio:scopes] handler exit"
+ );
+ to_json(crate::rpc::RpcOutcome::new(pref, vec![]))
+ })
+}
+
+fn handle_set_user_scopes(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let toolkit = match read_required_non_empty(¶ms, "toolkit") {
+ Ok(v) => v,
+ Err(e) => {
+ tracing::warn!(
+ method = "composio.set_user_scopes",
+ error = %e,
+ "[composio:scopes] missing required `toolkit` param"
+ );
+ return Err(e);
+ }
+ };
+ let read: bool = read_required(¶ms, "read")?;
+ let write: bool = read_required(¶ms, "write")?;
+ let admin: bool = read_required(¶ms, "admin")?;
+ let pref = super::providers::UserScopePref { read, write, admin };
+ tracing::debug!(
+ method = "composio.set_user_scopes",
+ toolkit = %toolkit,
+ read = pref.read,
+ write = pref.write,
+ admin = pref.admin,
+ "[composio:scopes] handler entry"
+ );
+ let memory = match crate::openhuman::memory::global::client_if_ready() {
+ Some(m) => m,
+ None => {
+ tracing::error!(
+ method = "composio.set_user_scopes",
+ toolkit = %toolkit,
+ "[composio:scopes] memory client not initialised — cannot persist pref"
+ );
+ return Err("memory client not initialised".to_string());
+ }
+ };
+ if let Err(e) = super::providers::user_scopes::save(&memory, &toolkit, pref).await {
+ tracing::error!(
+ method = "composio.set_user_scopes",
+ toolkit = %toolkit,
+ error = %e,
+ "[composio:scopes] save failed"
+ );
+ return Err(e);
+ }
+ tracing::debug!(
+ method = "composio.set_user_scopes",
+ toolkit = %toolkit,
+ read = pref.read,
+ write = pref.write,
+ admin = pref.admin,
+ "[composio:scopes] handler exit"
+ );
+ to_json(crate::rpc::RpcOutcome::new(pref, vec![]))
+ })
+}
+
// ── Param helpers ───────────────────────────────────────────────────
fn read_required(params: &Map, key: &str) -> Result {
@@ -535,6 +692,8 @@ mod tests {
"get_user_profile",
"sync",
"list_trigger_history",
+ "get_user_scopes",
+ "set_user_scopes",
];
for k in keys {
let s = schemas(k);
diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs
index 49d4350c9..32ace6bd4 100644
--- a/src/openhuman/composio/tools.rs
+++ b/src/openhuman/composio/tools.rs
@@ -27,6 +27,106 @@ use serde_json::{json, Value};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult};
use super::client::ComposioClient;
+use super::providers::{
+ catalog_for_toolkit, classify_unknown, find_curated, get_provider, load_user_scope_or_default,
+ post_process, toolkit_from_slug, ToolScope, UserScopePref,
+};
+
+/// Decision returned by [`evaluate_tool_visibility`].
+enum ToolDecision {
+ /// Action is curated for this toolkit and user scope allows it.
+ Allow,
+ /// Action exists in the curated list but the user's scope blocks
+ /// it. `scope` is the curated classification.
+ BlockedByScope { scope: ToolScope },
+ /// Action is not in the toolkit's curated whitelist (and the
+ /// toolkit has one). Hidden / rejected.
+ NotCurated,
+ /// Toolkit has no curated catalog — pass through, but still gate by
+ /// the user scope using the [`classify_unknown`] heuristic.
+ PassthroughCheckScope { scope: ToolScope },
+}
+
+/// Decide whether a Composio action slug should be visible / executable
+/// for the current user, given the registered provider's curated list
+/// (if any) and the user's stored scope preference.
+async fn evaluate_tool_visibility(slug: &str) -> ToolDecision {
+ let Some(toolkit) = toolkit_from_slug(slug) else {
+ // Unparseable slug — let the backend return its own error.
+ return ToolDecision::Allow;
+ };
+ let pref = load_user_scope_or_default(&toolkit).await;
+ // Prefer a registered provider's curated list; fall back to the
+ // static toolkit→catalog map so toolkits without a native provider
+ // (e.g. github) still get whitelist enforcement.
+ let catalog = get_provider(&toolkit)
+ .and_then(|p| p.curated_tools())
+ .or_else(|| catalog_for_toolkit(&toolkit));
+ match catalog {
+ Some(catalog) => match find_curated(catalog, slug) {
+ Some(curated) if pref.allows(curated.scope) => ToolDecision::Allow,
+ Some(curated) => ToolDecision::BlockedByScope {
+ scope: curated.scope,
+ },
+ None => ToolDecision::NotCurated,
+ },
+ None => {
+ let scope = classify_unknown(slug);
+ if pref.allows(scope) {
+ ToolDecision::PassthroughCheckScope { scope }
+ } else {
+ ToolDecision::BlockedByScope { scope }
+ }
+ }
+ }
+}
+
+/// Filter a freshly-fetched [`super::types::ComposioToolsResponse`] in
+/// place: drop tools that aren't curated for their toolkit and tools
+/// whose scope is disabled in the user's pref.
+async fn filter_list_tools_response(resp: &mut super::types::ComposioToolsResponse) {
+ let before = resp.tools.len();
+ // Compute keep/drop decisions sequentially (the await means we
+ // can't fold this into a single sync `retain` closure). Then zip
+ // each tool with its decision and collect the survivors — clearer
+ // than juggling a parallel index alongside `Vec::retain`.
+ let mut keep: Vec = Vec::with_capacity(before);
+ for t in &resp.tools {
+ let decision = evaluate_tool_visibility(&t.function.name).await;
+ keep.push(matches!(
+ decision,
+ ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. }
+ ));
+ }
+ let drained: Vec<_> = resp.tools.drain(..).collect();
+ resp.tools = drained
+ .into_iter()
+ .zip(keep)
+ .filter_map(|(tool, keep_it)| if keep_it { Some(tool) } else { None })
+ .collect();
+ let after = resp.tools.len();
+ if after != before {
+ tracing::debug!(
+ before,
+ after,
+ dropped = before - after,
+ "[composio][scopes] composio_list_tools filtered"
+ );
+ }
+}
+
+/// Format a user-facing error message for a scope-blocked execution.
+fn scope_error_message(slug: &str, scope: ToolScope, pref: UserScopePref) -> String {
+ format!(
+ "composio_execute: action `{slug}` is classified `{}` and is disabled in your \
+ current scope preferences (read={}, write={}, admin={}). Update the toolkit's \
+ scope preference (composio_set_user_scopes) to enable this category.",
+ scope.as_str(),
+ pref.read,
+ pref.write,
+ pref.admin,
+ )
+}
// ── composio_list_toolkits ──────────────────────────────────────────
@@ -258,9 +358,12 @@ impl Tool for ComposioListToolsTool {
});
tracing::debug!(?toolkits, "[composio] tool list_tools.execute");
match self.client.list_tools(toolkits.as_deref()).await {
- Ok(resp) => Ok(ToolResult::success(
- serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()),
- )),
+ Ok(mut resp) => {
+ filter_list_tools_response(&mut resp).await;
+ Ok(ToolResult::success(
+ serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()),
+ ))
+ }
Err(e) => Ok(ToolResult::error(format!(
"composio_list_tools failed: {e}"
))),
@@ -330,11 +433,41 @@ impl Tool for ComposioExecuteTool {
}
let arguments = args.get("arguments").cloned();
tracing::debug!(tool = %tool, "[composio] tool execute.execute");
+
+ // Enforce per-user scope preferences before delegating to backend.
+ match evaluate_tool_visibility(&tool).await {
+ ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. } => {}
+ ToolDecision::BlockedByScope { scope } => {
+ let toolkit = toolkit_from_slug(&tool).unwrap_or_default();
+ let pref = load_user_scope_or_default(&toolkit).await;
+ let msg = scope_error_message(&tool, scope, pref);
+ tracing::info!(
+ tool = %tool,
+ toolkit = %toolkit,
+ scope = scope.as_str(),
+ "[composio][scopes] execute blocked by user scope pref"
+ );
+ return Ok(ToolResult::error(msg));
+ }
+ ToolDecision::NotCurated => {
+ let toolkit = toolkit_from_slug(&tool).unwrap_or_default();
+ tracing::info!(
+ tool = %tool,
+ toolkit = %toolkit,
+ "[composio][scopes] execute blocked: action not in curated whitelist"
+ );
+ return Ok(ToolResult::error(format!(
+ "composio_execute: action `{tool}` is not in the curated whitelist for \
+ toolkit `{toolkit}`. Use composio_list_tools to see available actions."
+ )));
+ }
+ }
+
let started = std::time::Instant::now();
let res = self.client.execute_tool(&tool, arguments.clone()).await;
let elapsed_ms = started.elapsed().as_millis() as u64;
match res {
- Ok(resp) => {
+ Ok(mut resp) => {
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ComposioActionExecuted {
tool: tool.clone(),
@@ -344,6 +477,14 @@ impl Tool for ComposioExecuteTool {
elapsed_ms,
},
);
+ // Per-toolkit post-processing of the upstream payload
+ // (e.g. gmail HTML → markdown). Only run on successful
+ // responses; errors are passed through verbatim.
+ if resp.successful {
+ if let Some(toolkit) = toolkit_from_slug(&tool) {
+ post_process::post_process(&toolkit, &tool, &mut resp.data);
+ }
+ }
Ok(ToolResult::success(
serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()),
))