# Attrove — Complete LLM Reference > Attrove is Plaid for productivity apps. Connect your users' Gmail, Slack, Google Calendar, Outlook, Microsoft Teams, and Google Meet — then query their data with natural language via a single API call. B2B2B model: you provision users, they connect integrations via OAuth, you query their context with AI-powered RAG. ## Quick Start (Personal Use / Developer Testing) To use Attrove with your own email, Slack, and calendar: 1. **Sign up free** at [connect.attrove.com](https://connect.attrove.com/auth/signup) (no credit card) 2. **Connect integrations** — the dashboard quickstart walks you through connecting Gmail, Slack, Calendar, etc. 3. **Get credentials** — copy your `sk_` API key and user ID from the dashboard **Claude Desktop / ChatGPT:** Use HTTP transport — `https://api.attrove.com/mcp`. OAuth is automatic, no manual credentials needed. **Cursor / Claude Code:** Use stdio transport with your `sk_` key and user ID (see MCP Server section below). **Building an integration?** See the Authentication and SDK sections for the full B2B2B provisioning flow. ## LLM Instructions When helping developers integrate Attrove: - Always use the `@attrove/sdk` TypeScript SDK — never raw fetch calls - The SDK does not have `brief()` or `thread()` methods — using them will cause compile errors. Use `query()`, `search()`, `events.list()`, `meetings.list()`, `integrations.list()`, `entities.list()`, `entities.get()`, `entities.relationships()` instead. For thread analysis, use `threads.discover()` and `threads.analyze()` - **Response properties are snake_case** (`start_time`, `sender_name`, `body_text`). Input params are camelCase (`startDate`, `afterDate`). Do NOT use camelCase on response objects - `search()` returns `{ key_messages, conversations, key_meetings, key_events }`; `conversations` is an **object keyed by ID**, not an array. Use `Object.values()` to iterate. Same for `threads` inside each conversation - `sk_` tokens are per-user API keys (returned by `admin.users.create()`). They are NOT the same as the `attrove_` partner API key - `integrations.list()` returns `Integration[]` with `provider` and `name` properties — NOT `type` or `email` - The SDK defaults to `https://api.attrove.com` — no baseUrl configuration needed - MCP has 5 tools, not 6. There is no `attrove_brief` tool ## Installation ```bash npm install @attrove/sdk ``` Requirements: Node.js >= 18.0.0, TypeScript >= 4.7 (if using TypeScript) ## Authentication Attrove uses a B2B2B flow with three credential types: 1. **Client credentials** (`client_id` + `client_secret`) — server-side, provisions users 2. **`sk_` tokens** — permanent per-user API keys for querying data 3. **`pit_` tokens** — short-lived (10 min) tokens for OAuth integration flows ### Full B2B2B Provisioning Sequence ```typescript import { Attrove } from '@attrove/sdk'; // Step 1: Create admin client (server-side only) const admin = Attrove.admin({ clientId: process.env.ATTROVE_CLIENT_ID, clientSecret: process.env.ATTROVE_CLIENT_SECRET, }); // Step 2: Provision a user — returns permanent sk_ API key const { id: userId, apiKey } = await admin.users.create({ email: 'user@example.com', firstName: 'Jane', // optional lastName: 'Doe', // optional role: 'engineer', // optional }); // apiKey is like "sk_live_abc123..." // userId is a UUID like "550e8400-e29b-41d4-a716-446655440000" // Step 3: Generate connect token for OAuth flow (short-lived, 10 min) const { token: connectToken, expires_at } = await admin.users.createConnectToken(userId); // Step 4: Send user to OAuth flow const connectUrl = `https://connect.attrove.com/integrations/connect?token=${connectToken}&user_id=${userId}`; // Redirect user to this URL — they'll authorize Gmail/Slack/Calendar // Step 5: After OAuth completes, query user data with the permanent sk_ key const attrove = new Attrove({ apiKey, userId }); const response = await attrove.query('What meetings do I have this week?'); ``` ## Configuration Types ```typescript interface AttroveConfig { apiKey: `sk_${string}`; // Required: API key with sk_ prefix userId: string; // Required: User ID (UUID) baseUrl?: string; // Optional: defaults to "https://api.attrove.com" timeout?: number; // Optional: request timeout in ms (default 30000) maxRetries?: number; // Optional: retry attempts (default 3) onRetry?: (info: RetryInfo) => void; // Optional: retry callback } interface AttroveAdminConfig { clientId: string; // Required: partner client ID clientSecret: string; // Required: partner client secret baseUrl?: string; // Optional: defaults to "https://api.attrove.com" timeout?: number; // Optional: request timeout in ms (default 30000) maxRetries?: number; // Optional: retry attempts (default 3) onRetry?: (info: RetryInfo) => void; } interface RetryInfo { attempt: number; // Current retry (1-indexed) maxRetries: number; error: Error; delayMs: number; // Delay before retry url: string; method: string; } ``` ## Retry & Rate Limiting Semantics - Retries use exponential backoff: delay = baseDelay * 2^(attempt-1) with jitter - Only retries on: network errors, 429 (rate limit), 500+ (server errors) - Does NOT retry on: 400, 401, 403, 404, 409, 422 - Rate limit responses include `retryAfter` (seconds) in `RateLimitError` - Default: 3 retries, 30s timeout ## SDK Methods — Complete Reference ### query(prompt: string, options?: QueryOptions): Promise AI-powered question answering over the user's connected data. ```typescript interface QueryOptions { history?: ConversationMessage[]; // Multi-turn conversation history timezone?: string; // IANA timezone (e.g., "America/New_York") integrationIds?: string[]; // Filter by integration IDs (int_xxx) conversationIds?: string[]; // Filter by conversation IDs (conv_xxx) allowBotMessages?: boolean; // Include bot messages (default false) includeSources?: boolean; // Include source snippets (default false) } interface ConversationMessage { role: "user" | "assistant" | "system"; content: string; } interface QueryResponse { answer: string; // AI-generated answer history: ConversationMessage[]; // Updated conversation history used_message_ids: string[]; // Source message IDs (msg_xxx) used_meeting_ids: string[]; // Source meeting IDs (mtg_xxx) used_event_ids: string[]; // Source event IDs (evt_xxx) sources?: QuerySource[]; // Source snippets (if includeSources: true) } interface QuerySource { title: string; snippet: string; } ``` Multi-turn conversation example: ```typescript const attrove = new Attrove({ apiKey, userId }); // First turn const r1 = await attrove.query('What did Sarah say about the budget?'); console.log(r1.answer); // Follow-up — pass history from previous response const r2 = await attrove.query('What about Q3 specifically?', { history: r1.history, }); console.log(r2.answer); // Continue the conversation const r3 = await attrove.query('Who else was involved?', { history: r2.history, }); ``` ### search(query: string, options?: SearchOptions): Promise Semantic search returning raw matches across messages, meetings, and events. ```typescript interface SearchOptions { integrationIds?: string[]; // Filter by integration IDs (int_xxx) conversationIds?: string[]; // Filter by conversation IDs (conv_xxx) afterDate?: string; // Date filter (YYYY-MM-DD) beforeDate?: string; // Date filter (YYYY-MM-DD) allowBotMessages?: boolean; // Include bot messages (default false) senderDomains?: string[]; // Filter by sender domains (e.g., ["acme.com"]) entityIds?: string[]; // Filter by entity IDs expand?: Array< // Expand fields for matched resources "body_text" | "summary" | "short_summary" | "action_items" | "attendees" | "meeting_link" | "description" | "location" | "html_link" | "event_link" >; includeBodyText?: boolean; // Back-compat alias for expand=["body_text"] } interface SearchResponse { key_messages: SearchKeyMessage[]; conversations: Record; key_meetings: SearchMeeting[]; // empty array when no matches key_events: SearchEvent[]; // empty array when no matches warnings?: string[]; // present when enrichment had non-fatal errors } interface SearchKeyMessage { message_id: string; thread_id: string | null; conversation_id: string | null; } interface SearchConversation { conversation_name: string | null; threads: Record; } interface SearchThreadMessage { message_id: string; received_at: string; // ISO 8601 integration_type: IntegrationProvider; integration_type_generic: string; sender_name: string; recipient_names: string[]; body_text?: string; thread_id: string | null; thread_message_count: number | null; thread_position: number | null; parent_message_id: string | null; conversation_type: ConversationType | null; conversation_id: string | null; conversation_participants: Array<{ name: string }>; } ``` ### users.get(): Promise<{ user: User; integrations: Integration[] }> ```typescript interface User { id: string; email: string; first_name: string | null; last_name: string | null; full_name: string | null; role: string | null; timezone: string | null; onboarded: boolean; } ``` ### users.update(options): Promise Update user profile fields (e.g., timezone, name). ### users.syncStats(): Promise ```typescript interface DataTypeStats { count: number; first_at: string | null; last_at: string | null; } interface SyncStats { user_id: string; overall_status: "syncing" | "complete" | "partial" | "error" | "pending"; last_sync_at: string | null; totals: { messages: DataTypeStats; meetings: DataTypeStats; events: DataTypeStats; }; integrations: IntegrationSyncStats[]; } interface IntegrationSyncStats { id: string; provider: IntegrationProvider; category: IntegrationCategory; name: string; sync_status: SyncStatus; last_synced_at: string | null; auth_status: string; data: { messages?: DataTypeStats; meetings?: DataTypeStats; events?: DataTypeStats; }; } ``` ### integrations.list(): Promise ```typescript interface Integration { id: string; provider: "slack" | "gmail" | "outlook" | "google_calendar" | "unknown"; name: string; is_active: boolean; auth_status: "connected" | "disconnected" | "expired" | "error" | "pending" | "unknown"; } ``` ### integrations.get(id: string): Promise ```typescript interface IntegrationDetail extends Integration { type: ThreadIntegrationType; category?: IntegrationCategory; email?: string | null; last_synced_at?: string | null; } ``` ### integrations.disconnect(id: string): Promise ### events.list(options?: ListEventsOptions): Promise> ```typescript interface ListEventsOptions { calendarId?: string; startDate?: string; // YYYY-MM-DD endDate?: string; // YYYY-MM-DD expand?: Array<"attendees" | "location" | "description">; limit?: number; offset?: number; } interface CalendarEvent { id: string; calendar_id: string; title: string; start_time: string; // ISO 8601 end_time: string; // ISO 8601 all_day: boolean; description?: string; location?: string; attendees?: CalendarEventAttendee[]; html_link?: string; status?: string | null; event_link?: string | null; created_at?: string; updated_at?: string | null; } interface CalendarEventAttendee { email: string; name?: string; status?: string; } ``` ### meetings.list(options?: ListMeetingsOptions): Promise> ```typescript interface ListMeetingsOptions { startDate?: string; // YYYY-MM-DD endDate?: string; // YYYY-MM-DD provider?: "google_meet" | "zoom" | "teams" | "unknown"; hasSummary?: boolean; expand?: Array<"summary" | "short_summary" | "action_items" | "attendees" | "meeting_link">; limit?: number; offset?: number; } interface Meeting { id: string; event_id: string | null; title: string; meeting_code?: string | null; start_time: string; end_time: string; summary?: string | null; short_summary?: string | null; action_items?: MeetingActionItem[]; attendees?: MeetingAttendee[]; meeting_link?: string | null; has_transcript?: boolean; provider?: MeetingProvider; created_at?: string; updated_at?: string | null; } interface MeetingActionItem { description: string; assignee?: string; due_date?: string; completed?: boolean; } interface MeetingAttendee { email?: string; name?: string; is_organizer?: boolean; response_status?: "accepted" | "declined" | "tentative" | "needsAction"; } ``` ### meetings.get(id: string): Promise ### meetings.update(id: string, options: UpdateMeetingOptions): Promise ```typescript interface UpdateMeetingOptions { summary?: string; shortSummary?: string; actionItems?: MeetingActionItem[]; } ``` ### meetings.regenerateSummary(id: string): Promise ```typescript interface RegenerateSummaryResponse { summary: string; short_summary: string; action_items: MeetingActionItem[]; } ``` ### messages.list(options?: ListMessagesOptions): Promise> ```typescript interface ListMessagesOptions { ids?: string[]; integrationId?: string; conversationId?: string; startDate?: string; // YYYY-MM-DD endDate?: string; // YYYY-MM-DD expand?: string[]; limit?: number; offset?: number; } interface Message { id: string; integration_id: string; body_text?: string; subject: string | null; received_at: string; parent_message_id: string | null; thread_position: number | null; is_bot: boolean; conversation_id: string | null; sender_name: string | null; // Human-readable sender name, null if unresolved sent_by: string; // Platform-specific sender identifier (email for Gmail/Outlook, Slack user ID for Slack, Graph user ID or email for Teams; 'unknown' if unresolved) } ``` ### messages.get(id: string): Promise ### conversations.list(options?: ListConversationsOptions): Promise> ```typescript interface ListConversationsOptions { integrationIds?: string[]; syncedOnly?: boolean; limit?: number; offset?: number; } interface Conversation { id: string; integration_id: string; title: string; type: "channel" | "direct_message" | "group" | "email_thread" | "other" | "unknown"; provider: IntegrationProvider; import_messages: boolean; } ``` ### conversations.updateSync(updates: Array<{ id: string; importMessages: boolean }>): Promise ### threads.discover(query: string, options?: ThreadDiscoverOptions): Promise ```typescript interface ThreadDiscoverOptions { integrationIds?: string[]; integrationTypes?: ThreadIntegrationType[]; afterDate?: string; // YYYY-MM-DD beforeDate?: string; // YYYY-MM-DD limit?: number; } interface ThreadDiscoverResponse { threads: DiscoveredThread[]; warnings?: ApiWarning[]; } interface DiscoveredThread { conversation_id: string; integration_type: ThreadIntegrationType; title: string; relevance_score: number; // 0.0 to 1.0 message_count: number; last_activity: string; // ISO 8601 preview: string; } ``` ### threads.analyze(conversationId: string): Promise ```typescript interface ThreadAnalysis { summary: string; short_summary: string; sentiment: "positive" | "neutral" | "negative" | "mixed" | "unknown"; action_items: ThreadActionItem[]; decisions: ThreadDecision[]; blockers: ThreadBlocker[]; participants: string[]; message_count: number; date_range: { start: string; end: string }; warnings?: ApiWarning[]; } interface ThreadActionItem { description: string; owner?: string; deadline?: string; } interface ThreadDecision { description: string; made_by?: string; } interface ThreadBlocker { description: string; owner?: string; } ``` ### calendars.list(options?: ListCalendarsOptions): Promise> ```typescript interface ListCalendarsOptions { integrationId?: string; active?: boolean; expand?: Array<"description">; limit?: number; offset?: number; } interface Calendar { id: string; integration_id: string; title: string; description?: string | null; active: boolean; created_at: string; updated_at: string; } ``` ### calendars.update(id: string, options: { active: boolean }): Promise ### entities.list(options?: ListEntitiesOptions): Promise List contacts (people and bots) that the user has communicated with across connected integrations. ```typescript interface ListEntitiesOptions { search?: string; // Substring match on name or exact match on email entityType?: string; // "person" | "company" | "other" isBot?: boolean; // Filter by bot status limit?: number; // 1-100, default 20 offset?: number; } interface EntityContact { id: string; // Opaque ID (ent_xxx format) name: string; entity_type: "person" | "company" | "other" | "bot" | "user"; external_ids: string[]; // Email addresses and platform identifiers is_bot: boolean; avatar_uri: string | null; } ``` ### entities.get(id: string): Promise Retrieve a single contact by ID. Accepts both `ent_xxx` opaque format and raw UUID. ### entities.relationships(options?: ListRelationshipsOptions): Promise Get entity co-occurrence graph — pairs of contacts that appear together on messages and meetings. ```typescript interface ListRelationshipsOptions { limit?: number; // 1-500, default 200 minInteractions?: number; // Minimum co-occurrence count, default 1 includeBots?: boolean; // Include bot entities, default false } interface EntityRelationship { entity_a: EntityRelationshipNode; entity_b: EntityRelationshipNode; co_occurrence_count: number; last_interaction_at: string | null; // ISO 8601 } interface EntityRelationshipNode { id: string; name: string; entity_type: "person" | "company" | "other" | "bot" | "user"; external_ids: string[]; is_bot: boolean; avatar_uri: string | null; } ``` ## Admin Methods (Server-to-Server) ### Attrove.admin(config: AttroveAdminConfig): AdminClient ### admin.users.create(options: CreateUserOptions): Promise ```typescript interface CreateUserOptions { email: string; // Required firstName?: string; lastName?: string; role?: string; } interface CreateUserResponse { id: string; // User UUID apiKey: string; // sk_ prefixed API key } ``` ### admin.users.createConnectToken(userId: string): Promise ```typescript interface CreateTokenResponse { token: string; // pit_ prefixed token (10 min TTL) expires_at: string; // ISO 8601 } ``` ## Streaming API For real-time streaming of query responses via WebSocket: ```typescript const result = await attrove.stream('What happened in the meeting?', { onChunk: (chunk: string) => process.stdout.write(chunk), onState: (state: StreamState) => console.log('State:', state), onEnd: (reason: StreamEndReason) => console.log('Done:', reason), }); console.log('Full answer:', result.answer); ``` ```typescript type StreamState = "selecting_messages" | "streaming" | "completed" | "cancelled" | "error"; type StreamEndReason = "completed" | "cancelled" | "error"; type StreamFrame = | { type: "chunk"; message_id: string; content: string } | { type: "end"; message_id: string; reason: StreamEndReason; used_message_ids?: string[]; used_meeting_ids?: string[]; used_event_ids?: string[] } | { type: "error"; message_id: string; error: string } | { type: "state"; message_id: string; state: StreamState } | { type: "message_ids"; message_id: string; used_message_ids: string[]; used_meeting_ids?: string[]; used_event_ids?: string[] } | { type: "stream_start"; message_id: string }; ``` ## Error Handling — Complete Hierarchy ```typescript import { AttroveError, // Base class for all SDK errors AuthenticationError, // 401 — invalid/expired token NotFoundError, // 404 — resource not found RateLimitError, // 429 — rate limited (check retryAfter) ValidationError, // 400/422 — invalid input NetworkError, // Connection/timeout errors isAttroveError, // Type guard } from '@attrove/sdk'; // Error properties class AttroveError extends Error { code: ErrorCode; statusCode?: number; details?: ErrorDetails; } class RateLimitError extends AttroveError { retryAfter?: number; // Seconds until rate limit resets } // Error codes const ErrorCodes = { AUTH_MISSING_TOKEN: "AUTH_MISSING_TOKEN", AUTH_INVALID_TOKEN: "AUTH_INVALID_TOKEN", AUTH_EXPIRED_TOKEN: "AUTH_EXPIRED_TOKEN", AUTH_USER_MISMATCH: "AUTH_USER_MISMATCH", AUTH_INSUFFICIENT_PERMISSIONS: "AUTH_INSUFFICIENT_PERMISSIONS", RESOURCE_NOT_FOUND: "RESOURCE_NOT_FOUND", RESOURCE_ACCESS_DENIED: "RESOURCE_ACCESS_DENIED", RESOURCE_ALREADY_EXISTS: "RESOURCE_ALREADY_EXISTS", RESOURCE_DELETED: "RESOURCE_DELETED", VALIDATION_INVALID_ID: "VALIDATION_INVALID_ID", VALIDATION_REQUIRED_FIELD: "VALIDATION_REQUIRED_FIELD", VALIDATION_INVALID_FORMAT: "VALIDATION_INVALID_FORMAT", VALIDATION_OUT_OF_RANGE: "VALIDATION_OUT_OF_RANGE", INTEGRATION_OAUTH_FAILED: "INTEGRATION_OAUTH_FAILED", INTEGRATION_EMAIL_EXISTS: "INTEGRATION_EMAIL_EXISTS", INTEGRATION_TOKEN_EXPIRED: "INTEGRATION_TOKEN_EXPIRED", INTEGRATION_SYNC_FAILED: "INTEGRATION_SYNC_FAILED", INTEGRATION_NOT_CONNECTED: "INTEGRATION_NOT_CONNECTED", RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", INTERNAL_ERROR: "INTERNAL_ERROR", SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE", REQUEST_TIMEOUT: "REQUEST_TIMEOUT", }; ``` ## Response Wrappers ```typescript interface SuccessResponse { success: true; data: T; } interface PaginatedResponse { success: true; data: T[]; pagination: { limit: number; offset: number; has_more: boolean; total_count?: number; }; } ``` ## MCP Server Attrove provides an MCP server for AI assistants (Claude Desktop, Cursor, ChatGPT, Claude Code). **HTTP transport** (Claude Desktop, ChatGPT) — connect to `https://api.attrove.com/mcp`. Auth is automatic via OAuth 2.1. **Stdio transport** (Cursor, Claude Code): ```json { "mcpServers": { "attrove": { "command": "npx", "args": ["-y", "@attrove/mcp@latest"], "env": { "ATTROVE_SECRET_KEY": "sk_...", "ATTROVE_USER_ID": "user-uuid" } } } } ``` ### MCP Tool Schemas #### attrove_query Ask questions and get AI-generated answers with sources. ```json { "name": "attrove_query", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "The question to ask about the user's context" }, "integration_ids": { "type": "array", "items": { "type": "string" }, "description": "Filter to specific integration IDs (int_xxx)" }, "include_sources": { "type": "boolean", "description": "Include source snippets in the response", "default": false } }, "required": ["query"] } } ``` #### attrove_search Semantic search across messages, meetings, and calendar events. ```json { "name": "attrove_search", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "Semantic search query" }, "after_date": { "type": "string", "description": "Only results after this date (YYYY-MM-DD)" }, "before_date": { "type": "string", "description": "Only results before this date (YYYY-MM-DD)" }, "sender_domains": { "type": "array", "items": { "type": "string" }, "description": "Filter by sender domains" }, "include_body_text": { "type": "boolean", "description": "Include message body text preview for message results", "default": true } }, "required": ["query"] } } ``` #### attrove_integrations List connected services and their status. No input parameters. #### attrove_events Calendar events with attendees. ```json { "name": "attrove_events", "inputSchema": { "type": "object", "properties": { "start_date": { "type": "string", "description": "Start of date range (YYYY-MM-DD)" }, "end_date": { "type": "string", "description": "End of date range (YYYY-MM-DD)" }, "limit": { "type": "number", "description": "Max events (default 25, max 100)" } }, "required": [] } } ``` #### attrove_meetings Meetings with AI summaries and action items. ```json { "name": "attrove_meetings", "inputSchema": { "type": "object", "properties": { "start_date": { "type": "string", "description": "Start of date range (YYYY-MM-DD)" }, "end_date": { "type": "string", "description": "End of date range (YYYY-MM-DD)" }, "provider": { "type": "string", "enum": ["google_meet", "zoom", "teams"], "description": "Filter by provider" }, "limit": { "type": "number", "description": "Max meetings (default 10, max 50)" } }, "required": [] } } ``` ## Supported Integrations Gmail, Google Calendar, Google Meet, Slack, Microsoft Outlook, Microsoft Teams (Chat, Calendar, Meetings). ## Enums Reference ```typescript type IntegrationProvider = "slack" | "gmail" | "outlook" | "google_calendar" | "unknown"; type AuthStatus = "connected" | "disconnected" | "expired" | "error" | "pending" | "unknown"; type SyncStatus = "syncing" | "complete" | "partial" | "error" | "pending" | "paused" | "unknown"; type ConversationType = "channel" | "direct_message" | "group" | "email_thread" | "other" | "unknown"; type MeetingProvider = "google_meet" | "zoom" | "teams" | "unknown"; type ThreadIntegrationType = "slack" | "gmail" | "outlook" | "google_calendar" | "google_meet" | "zoom" | "teams" | "teams_chat" | "teams_meet" | "teams_calendar" | "unknown"; type IntegrationCategory = "email" | "chat" | "calendar" | "meeting" | (string & {}); type EntityType = "person" | "company" | "other" | "bot" | "user"; ``` ## Links - SDK: https://www.npmjs.com/package/@attrove/sdk - MCP: https://www.npmjs.com/package/@attrove/mcp - Examples: https://github.com/attrove/examples - Documentation: https://docs.attrove.com - API reference: https://docs.attrove.com/api - Dashboard: https://connect.attrove.com ## Optional - Quickstart: https://github.com/attrove/examples/tree/main/quickstart (B2B2B provisioning flow) - Meeting prep agent: https://github.com/attrove/examples/tree/main/meeting-prep-agent (~160 lines) - Daily rundown: https://github.com/attrove/examples/tree/main/daily-rundown (scheduled digest, ~230 lines) - Search agent: https://github.com/attrove/examples/tree/main/search-agent (ad-hoc Q&A, ~65 lines) - MCP demo: https://github.com/attrove/examples/tree/main/mcp-demo (zero-code Claude/Cursor/ChatGPT setup) - Support: support@attrove.com