# Third-Party Service Integration Operations Guide Complete guide from account creation to implementation for integrating Pipedrive, PandaDoc, Zoho CRM, Linear, and Obsidian into the MCP Elixir application. Where services overlap in functionality (e.g., Pipedrive and Zoho are both CRMs), this guide identifies the stronger tool for each capability and explains why. See [Service Overlap & Selection Guide](#service-overlap--selection-guide) for the full comparison. --- ## Table of Contents 1. [Architecture Overview](#architecture-overview) 2. [Pipedrive (CRM — Sales Pipeline)](#pipedrive) 3. [PandaDoc (Document Signing)](#pandadoc) 4. [Zoho CRM (CRM — Full-Suite)](#zoho-crm) 5. [Linear (Project Management)](#linear) 6. [Obsidian (Knowledge Base)](#obsidian) 7. [Service Overlap & Selection Guide](#service-overlap--selection-guide) 8. [Implementation Plan](#implementation-plan) --- ## Architecture Overview Each integration follows the same pattern in our app: ``` lib/ pipedrive.ex # REST API client (HTTP calls, auth, token refresh) pipedrive_rest.ex # REST endpoint handlers for our app pipedrive_web.ex # HTML UI pandadoc.ex pandadoc_rest.ex pandadoc_web.ex zoho.ex zoho_rest.ex zoho_web.ex linear.ex # GraphQL API client (queries, mutations, auth) linear_rest.ex linear_web.ex obsidian.ex # Local REST API client (vault CRUD, search, periodic notes) obsidian_rest.ex obsidian_web.ex ``` Shared infrastructure: - **OAuth token storage**: Sqler table `oauth_tokens` (encrypted access/refresh tokens per user per service) - **Webhook receiver**: Routes in `my_mcp_server_router.ex` for incoming webhooks, signature verification per service - **MCP tools**: Exposed in `my_mcp_server.ex` for chat/agent access - **AccessControlled**: One `@permission` key per service - **ServiceClient**: Shared HTTP module with two interfaces — `ServiceClient.rest/5` for REST APIs (Pipedrive, PandaDoc, Zoho, Obsidian) and `ServiceClient.graphql/4` for GraphQL (Linear). Both handle auth header injection, 401 refresh+retry, and 429 backoff. **API styles:** | Service | Protocol | Auth Header Format | |---------|----------|-------------------| | Pipedrive (personal) | REST | Query param: `?api_token={token}` | | Pipedrive (OAuth) | REST | `Authorization: Bearer {token}` | | PandaDoc (sandbox) | REST | `Authorization: API-Key {key}` | | PandaDoc (OAuth) | REST | `Authorization: Bearer {token}` | | Zoho | REST | `Authorization: Zoho-oauthtoken {token}` | | Linear (personal) | GraphQL | `Authorization: {key}` (no prefix) | | Linear (OAuth) | GraphQL | `Authorization: Bearer {token}` | | Obsidian | REST (local) | `Authorization: Bearer {api_key}` | --- ## Pipedrive **Role in our stack:** Sales pipeline management — focused CRM for deal tracking, sales activities, and revenue forecasting. Preferred over Zoho for pipeline visualization, deal management, and sales team workflow. See [overlap guide](#crm-pipedrive-vs-zoho) for when to use Pipedrive vs Zoho. ### 1. Account Setup 1. Go to [pipedrive.com](https://www.pipedrive.com) and sign up for a **14-day free trial** (no credit card required) 2. No free plan exists — a paid subscription is required after trial 3. Plans: Lite ($14/seat/mo), Growth ($39), Premium ($49), Ultimate ($74) — all include API access ### 2. API Credentials **Option A: Personal API Token** (for internal/single-user use) 1. In Pipedrive web app: Profile icon > Company settings > Personal preferences > API tab 2. Copy the API token 3. Pass as query parameter: `?api_token=YOUR_TOKEN` **Option B: OAuth 2.0** (for multi-user apps) 1. Go to [Pipedrive Developer Hub](https://developers.pipedrive.com/) 2. Create a new app, set callback URL and select scopes 3. Receive `client_id` and `client_secret` 4. OAuth tokens use header: `Authorization: Bearer {access_token}` ### 3. OAuth 2.0 Flow **Authorization URL:** ``` https://oauth.pipedrive.com/oauth/authorize ?client_id={client_id} &redirect_uri={redirect_uri} &scope={scopes} ``` **Token Exchange:** ``` POST https://oauth.pipedrive.com/oauth/token grant_type=authorization_code code={code} redirect_uri={redirect_uri} client_id={client_id} client_secret={client_secret} ``` **Token Refresh:** ``` POST https://oauth.pipedrive.com/oauth/token grant_type=refresh_token refresh_token={refresh_token} client_id={client_id} client_secret={client_secret} ``` - Access token: ~1 hour expiry - Refresh token: 90-day rolling lifetime (renews on use) - On refresh, both new access and refresh tokens may be issued — store both **Key Scopes:** | Scope | Access | |-------|--------| | `deals:full` | CRUD deals | | `contacts:full` | CRUD persons and organizations | | `leads:full` | CRUD leads | | `activities:full` | CRUD activities | | `products:full` | CRUD products | | `projects:full` | CRUD projects | | `webhooks:full` | Manage webhooks | | `admin` | Administrative access | ### 4. Base URL ``` https://{COMPANYDOMAIN}.pipedrive.com/api/v2/ (preferred) https://api.pipedrive.com/api/v2/ (generic) ``` **v1 vs v2:** Prefer v2 endpoints — they cost 50% fewer rate-limit tokens. However, some resources have no v2 equivalent yet: **Leads, Notes, Files, Users, Roles** remain on `/v1/` paths. The tables below mark which version each endpoint uses. When v2 becomes available for these resources, migrate to reduce rate-limit cost. ### 5. Rate Limits - **Daily token budget**: 30,000 base tokens × plan multiplier × seats - **Burst**: 20–120 requests per 2-second window (varies by plan) - v2 endpoints consume 50% fewer tokens than v1 - Headers: `X-RateLimit-Remaining`, `X-Daily-Requests-Left` ### 6. API Resources #### Deals | Method | Path | Description | |--------|------|-------------| | GET | `/api/v2/deals` | List deals | | GET | `/api/v2/deals/{id}` | Get deal | | POST | `/api/v2/deals` | Create deal | | PATCH | `/api/v2/deals/{id}` | Update deal | | DELETE | `/api/v2/deals/{id}` | Delete deal | | GET | `/api/v2/deals/search` | Search deals | | POST | `/api/v2/deals/{id}/products` | Attach product to deal | | GET | `/api/v2/deals/{id}/followers` | List deal followers | | PUT | `/api/v2/deals/{id}/merge` | Merge deals | #### Persons (Contacts) | Method | Path | Description | |--------|------|-------------| | GET | `/api/v2/persons` | List persons | | GET | `/api/v2/persons/{id}` | Get person | | POST | `/api/v2/persons` | Create person | | PATCH | `/api/v2/persons/{id}` | Update person | | DELETE | `/api/v2/persons/{id}` | Delete person | | GET | `/api/v2/persons/search` | Search persons | #### Organizations | Method | Path | Description | |--------|------|-------------| | GET | `/api/v2/organizations` | List organizations | | GET | `/api/v2/organizations/{id}` | Get organization | | POST | `/api/v2/organizations` | Create organization | | PATCH | `/api/v2/organizations/{id}` | Update organization | | DELETE | `/api/v2/organizations/{id}` | Delete organization | | GET | `/api/v2/organizations/search` | Search organizations | #### Activities | Method | Path | Description | |--------|------|-------------| | GET | `/api/v2/activities` | List activities | | GET | `/api/v2/activities/{id}` | Get activity | | POST | `/api/v2/activities` | Create activity | | PATCH | `/api/v2/activities/{id}` | Update activity | | DELETE | `/api/v2/activities/{id}` | Delete activity | #### Pipelines & Stages | Method | Path | Description | |--------|------|-------------| | GET | `/api/v2/pipelines` | List pipelines | | POST | `/api/v2/pipelines` | Create pipeline | | GET | `/api/v2/stages` | List stages | | POST | `/api/v2/stages` | Create stage | #### Leads (v1 only) | Method | Path | Description | |--------|------|-------------| | GET | `/v1/leads` | List leads | | POST | `/v1/leads` | Create lead | | PATCH | `/v1/leads/{id}` | Update lead | | DELETE | `/v1/leads/{id}` | Delete lead | | POST | `/api/v2/leads/{id}/convert/deal` | Convert lead to deal (v2) | #### Products | Method | Path | Description | |--------|------|-------------| | GET | `/api/v2/products` | List products | | POST | `/api/v2/products` | Create product | | PATCH | `/api/v2/products/{id}` | Update product | | DELETE | `/api/v2/products/{id}` | Delete product | #### Notes (v1 only — uses PUT not PATCH for updates) | Method | Path | Description | |--------|------|-------------| | GET | `/v1/notes` | List notes | | POST | `/v1/notes` | Create note | | PUT | `/v1/notes/{id}` | Update note | | DELETE | `/v1/notes/{id}` | Delete note | #### Files (v1 only) | Method | Path | Description | |--------|------|-------------| | GET | `/v1/files` | List files | | POST | `/v1/files` | Upload file | | GET | `/v1/files/{id}/download` | Download file | | DELETE | `/v1/files/{id}` | Delete file | #### Goals | Method | Path | Description | |--------|------|-------------| | GET | `/v1/goals` | List goals | | POST | `/v1/goals` | Create goal | | GET | `/v1/goals/{id}` | Get goal | | PUT | `/v1/goals/{id}` | Update goal | | DELETE | `/v1/goals/{id}` | Delete goal | | GET | `/v1/goals/{id}/results` | Get goal results | #### Users & Roles (v1 only) | Method | Path | Description | |--------|------|-------------| | GET | `/v1/users` | List users | | GET | `/v1/users/me` | Current user | | GET | `/v1/roles` | List roles | #### Global Search | Method | Path | Description | |--------|------|-------------| | GET | `/api/v2/itemSearch` | Search across all entity types | **Search strategy:** Use `itemSearch` for cross-entity discovery (user types a name, you don't know if it's a deal, person, or org). Use per-resource search endpoints (`/deals/search`, `/persons/search`) when you already know the entity type — they return richer, typed results and support more filter options. ### 7. Webhooks **Create webhook:** ``` POST /v1/webhooks subscription_url: "https://yourapp.com/webhooks/pipedrive" event_action: "added" | "updated" | "deleted" | "merged" | "*" event_object: "deal" | "person" | "organization" | "activity" | "lead" | "note" | "product" | "*" ``` **Events:** Combine action + object: `added.deal`, `updated.person`, `deleted.organization`, `*.deal`, `*.*` **Signature Verification:** Pipedrive signs webhook payloads with your app's client secret. Each delivery includes these headers: - `X-Pipedrive-Signature`: HMAC-SHA256 of the raw body using your OAuth app's `client_secret` - Verify by computing `HMAC-SHA256(client_secret, raw_body)` and comparing with constant-time equality - Pipedrive sends from a rotating set of IPs — signature verification is the reliable method ### 8. MCP Tools to Implement ``` pipedrive_deals_list, pipedrive_deal_get, pipedrive_deal_create, pipedrive_deal_update pipedrive_persons_list, pipedrive_person_get, pipedrive_person_create, pipedrive_person_update pipedrive_orgs_list, pipedrive_org_get, pipedrive_org_create pipedrive_activities_list, pipedrive_activity_create pipedrive_search (global itemSearch — use for cross-entity discovery) pipedrive_pipelines_list pipedrive_leads_list, pipedrive_lead_create pipedrive_goals_list ``` **Overlap note:** Pipedrive persons/orgs overlap with Zoho contacts/accounts. Pipedrive is the primary interface for sales contacts. See [overlap guide](#contacts--companies). --- ## PandaDoc **Role in our stack:** Document generation, e-signatures, and contract lifecycle management. No overlap with other services — PandaDoc is the sole document signing tool. ### 1. Account Setup 1. Sign up at [pandadoc.com/api](https://www.pandadoc.com/api/) — free sandbox, no credit card 2. 14-day trial with Enterprise features, then free sandbox forever 3. Sandbox limitations: watermarks on PDFs, `[DEV]` prefix, 10 req/min rate limit 4. Production requires Enterprise plan (custom pricing) ### 2. API Credentials **Sandbox API Key:** 1. Log in to PandaDoc 2. Settings > Integrations > API & Keys (or [Developer Dashboard](https://app.pandadoc.com/a/#/api-dashboard/configuration)) 3. Generate sandbox API key 4. Sandbox API keys support all endpoints but **do not support token refresh** — the key itself is long-lived **Header:** `Authorization: API-Key {your_api_key}` **OAuth 2.0** (production): **Authorization URL:** ``` GET https://app.pandadoc.com/oauth2/authorize ?client_id={client_id} &redirect_uri={redirect_uri} &scope=read+write &response_type=code ``` **Token Exchange:** ``` POST https://api.pandadoc.com/oauth2/access_token grant_type=authorization_code client_id={client_id} client_secret={client_secret} code={code} scope=read+write ``` **Token Refresh:** ``` POST https://api.pandadoc.com/oauth2/access_token grant_type=refresh_token client_id={client_id} client_secret={client_secret} refresh_token={refresh_token} ``` - Access token: ~1 year expiry (`expires_in: 31535999`) — long-lived but still store refresh token - Refresh tokens are issued with OAuth flow, **not** with sandbox API keys - Scopes: `read+write` (full) or `read` (view only) ### 3. Base URL ``` https://api.pandadoc.com/public/v1/ ``` ### 4. Rate Limits | Environment | Limit | |-------------|-------| | Sandbox | 10 req/min/endpoint | | Production (create from PDF) | 300 req/min | | Production (create from template) | 500 req/min | | Production (get details) | 600 req/min | | Production (download) | 100 req/min | | Production (default) | 60 req/min | ### 5. API Resources #### Documents | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/documents` | List/search documents | | POST | `/public/v1/documents` | Create from template or URL | | GET | `/public/v1/documents/{id}` | Get document status | | PATCH | `/public/v1/documents/{id}` | Update document (draft only) | | DELETE | `/public/v1/documents/{id}` | Delete document | | GET | `/public/v1/documents/{id}/details` | Full document data (recipients, fields, pricing) | | POST | `/public/v1/documents/{id}/send` | Send for signing | | PATCH | `/public/v1/documents/{id}/status` | Change status (completed, expired, declined) | | POST | `/public/v1/documents/{id}/draft` | Revert to draft | | GET | `/public/v1/documents/{id}/download` | Download as PDF | | GET | `/public/v1/documents/{id}/download-protected` | Download signed PDF (production only) | | GET | `/public/v1/documents/{id}/audit-trail` | Full audit trail | **Async creation:** `POST /documents` returns immediately with `id` and `status: "document.uploaded"`, but the document is **not ready** for sending until the status transitions to `"document.draft"`. Poll `GET /documents/{id}` or use the `document_state_changed` webhook to know when the document is ready. Do not call `/send` on a non-draft document — it will fail. #### Document Recipients | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/documents/{id}/recipients` | List recipients | | POST | `/public/v1/documents/{id}/recipients` | Add recipient | | PATCH | `/public/v1/documents/{id}/recipients/{rid}` | Update recipient | | DELETE | `/public/v1/documents/{id}/recipients/{rid}` | Remove recipient | #### Document Fields & Attachments | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/documents/{id}/fields` | List fields and values | | GET | `/public/v1/documents/{id}/attachments` | List attachments | #### Embedded Sessions | Method | Path | Description | |--------|------|-------------| | POST | `/public/v1/documents/{id}/editing-sessions` | Create embedded editor | | POST | `/public/v1/documents/{id}/session` | Create embedded signing session | #### Templates | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/templates` | List templates | | POST | `/public/v1/templates` | Create template | | GET | `/public/v1/templates/{id}` | Get template details | | DELETE | `/public/v1/templates/{id}` | Delete template | #### Forms | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/forms` | List forms | | GET | `/public/v1/forms/{id}` | Get form details | #### Folders | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/folders` | List folders | | POST | `/public/v1/folders` | Create folder | | PATCH | `/public/v1/folders/{id}` | Update folder | | DELETE | `/public/v1/folders/{id}` | Delete folder | #### Content Library | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/content-library-items` | List items | | POST | `/public/v1/content-library-items` | Create item (file upload) | | GET | `/public/v1/content-library-items/{id}` | Get item details | #### Contacts & Members | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/contacts` | List contacts | | GET | `/public/v1/members` | List workspace members | #### Quotes/Pricing | Method | Path | Description | |--------|------|-------------| | GET | `/public/v1/documents/{id}/quotes` | Get pricing tables | | PATCH | `/public/v1/documents/{id}/quotes/{qid}` | Update quote | #### Linked Objects (CRM Integration) | Method | Path | Description | |--------|------|-------------| | POST | `/public/v1/documents/{id}/linked-objects` | Link to CRM entity | | GET | `/public/v1/documents/{id}/linked-objects` | Get linked objects | | DELETE | `/public/v1/documents/{id}/linked-objects/{lid}` | Remove link | ### 6. Webhooks Configure at Settings > Integrations > Webhooks or via API. **Events:** | Event | Description | |-------|-------------| | `document_state_changed` | Status transitions (draft → sent → viewed → completed) | | `recipient_completed` | Recipient finished signing | | `document_updated` | Content/metadata modified | | `document_deleted` | Document removed | | `document_creation_failed` | Async creation failed | | `document_completed_pdf_ready` | PDF ready for download | **Signature Verification:** PandaDoc includes a shared secret for webhook verification: 1. When configuring the webhook, PandaDoc provides a **shared key** 2. Each delivery includes an `X-PandaDoc-Signature` header containing an HMAC-SHA256 hex digest 3. Compute `HMAC-SHA256(shared_key, raw_body)` and compare with constant-time equality 4. Additionally, verify the `date_created` field in the payload is recent (within 5 minutes) for replay protection ### 7. MCP Tools to Implement ``` pandadoc_documents_list, pandadoc_document_get, pandadoc_document_create pandadoc_document_send, pandadoc_document_status, pandadoc_document_download pandadoc_templates_list, pandadoc_template_get pandadoc_document_details (fields, recipients, pricing) ``` --- ## Zoho CRM **Role in our stack:** Full-suite CRM for enterprise record management — contacts, accounts, custom modules, bulk operations, and SQL-like queries (COQL). Preferred over Pipedrive for multi-module data, reporting, bulk imports, and custom module schemas. See [overlap guide](#crm-pipedrive-vs-zoho) for when to use Zoho vs Pipedrive. ### 1. Account Setup 1. Sign up at [zoho.com/crm](https://www.zoho.com/crm/) — **Free Edition** supports up to 3 users forever 2. For development: [Developer Edition](https://www.zoho.com/crm/developer/developer-edition.html) — free, full Ultimate features 3. Go to [API Console](https://api-console.zoho.com/) > GET STARTED 4. Choose **Server-Based Application** (not Self Client — see rationale below) 5. Receive `client_id` and `client_secret` ### 2. OAuth 2.0 Flow **Use Authorization Code flow, not Self-Client.** The Self-Client (client credentials) flow looks simpler but produces tokens with no refresh token — you'd need to re-authenticate every hour by generating a new grant code in the Zoho console manually. The Authorization Code flow issues a permanent refresh token, which the token refresh GenServer can use automatically. Self-Client is only appropriate for one-off scripts, not a long-running server. **Critical: Data center matters.** All URLs depend on where the user's account is hosted: | Data Center | Accounts Server | API Domain | |-------------|-----------------|------------| | US | `https://accounts.zoho.com` | `https://www.zohoapis.com` | | EU | `https://accounts.zoho.eu` | `https://www.zohoapis.eu` | | IN | `https://accounts.zoho.in` | `https://www.zohoapis.in` | | AU | `https://accounts.zoho.com.au` | `https://www.zohoapis.com.au` | **Authorization URL:** ``` GET {accounts-server}/oauth/v2/auth ?response_type=code &client_id={client_id} &scope=ZohoCRM.modules.ALL,ZohoCRM.settings.ALL &redirect_uri={redirect_uri} &access_type=offline ``` **Token Exchange:** ``` POST {accounts-server}/oauth/v2/token grant_type=authorization_code client_id={client_id} client_secret={client_secret} code={code} redirect_uri={redirect_uri} ``` **Token Refresh:** ``` POST {accounts-server}/oauth/v2/token grant_type=refresh_token client_id={client_id} client_secret={client_secret} refresh_token={refresh_token} ``` **IMPORTANT gotchas:** - Access token expires in **exactly 1 hour** (3600s) — the token refresh GenServer must proactively refresh before expiry - Refresh token **never expires** (unless revoked), issued only once with the authorization code exchange — if you lose it, the user must re-authorize - Authorization code is **single-use, expires in 2 minutes** - Header format: `Authorization: Zoho-oauthtoken {access_token}` (NOT `Bearer`) - Store the `api_domain` from token response in the `extra` JSON column — use it for all API calls **Key Scopes:** | Scope | Description | |-------|-------------| | `ZohoCRM.modules.ALL` | All modules, all operations | | `ZohoCRM.modules.leads` | Leads | | `ZohoCRM.modules.contacts` | Contacts | | `ZohoCRM.modules.deals` | Deals | | `ZohoCRM.modules.accounts` | Accounts (companies) | | `ZohoCRM.settings.ALL` | All settings | | `ZohoCRM.users.ALL` | User management | | `ZohoCRM.coql.READ` | COQL query execution | | `ZohoCRM.bulk.ALL` | Bulk operations | ### 3. Base URL ``` {api_domain}/crm/v8/{resource} ``` Where `api_domain` is from the token response (e.g., `https://www.zohoapis.com`). Stored in `oauth_tokens.extra` JSON. ### 4. Rate Limits **Concurrency** (simultaneous calls per org per app): | Edition | Concurrent | |---------|-----------| | Free | 5 | | Standard | 10 | | Professional | 15 | | Enterprise | 20 | | Ultimate | 25 | **Credits** (24-hour rolling window): | Edition | Base | Per User | Max | |---------|------|----------|-----| | Free | 5,000 | — | 5,000 | | Standard | 50,000 | +250 | 100,000 | | Professional | 50,000 | +500 | 3,000,000 | | Enterprise | 50,000 | +1,000 | 5,000,000 | ### 5. API Resources All endpoints below work on any module. Replace `{module}` with: `Leads`, `Contacts`, `Deals`, `Accounts`, `Tasks`, `Events`, `Calls`, `Products`, `Quotes`, `Sales_Orders`, `Purchase_Orders`, `Invoices`, `Campaigns`, `Cases`, `Vendors`, `Notes`, or custom module names. **Why Zoho's generic module API is powerful:** A single set of 5 CRUD functions (`list`, `get`, `create`, `update`, `delete`) covers ALL standard and custom modules. Pipedrive needs separate endpoints per entity type. This is why Zoho is preferred for multi-module data operations and why the MCP tools use a generic `zoho_record_*` pattern with a `module` parameter. #### Core Record Operations | Method | Path | Description | |--------|------|-------------| | GET | `/crm/v8/{module}` | List records (up to 200/page) | | GET | `/crm/v8/{module}/{id}` | Get record | | POST | `/crm/v8/{module}` | Create records (up to 100/call) | | PUT | `/crm/v8/{module}/{id}` | Update record | | DELETE | `/crm/v8/{module}/{id}` | Delete record | | POST | `/crm/v8/{module}/upsert` | Insert or update (dedup check) | | GET | `/crm/v8/{module}/search` | Search records | | GET | `/crm/v8/{module}/deleted` | List deleted records | #### Related Records | Method | Path | Description | |--------|------|-------------| | GET | `/crm/v8/{module}/{id}/{related_list}` | Get related records | | PUT | `/crm/v8/{module}/{id}/{related_list}/{rid}` | Update relationship | | DELETE | `/crm/v8/{module}/{id}/{related_list}/{rid}` | Delink record | #### Notes & Attachments | Method | Path | Description | |--------|------|-------------| | POST | `/crm/v8/notes` | Create note | | GET | `/crm/v8/{module}/{id}/notes` | List notes for record | | POST | `/crm/v8/{module}/{id}/attachments` | Upload attachment | | GET | `/crm/v8/{module}/{id}/attachments` | List attachments | #### Tags | Method | Path | Description | |--------|------|-------------| | GET | `/crm/v8/settings/tags?module={module}` | List tags for a module | | POST | `/crm/v8/settings/tags?module={module}` | Create tag | | PUT | `/crm/v8/settings/tags/{id}` | Update tag | | DELETE | `/crm/v8/settings/tags/{id}` | Delete tag | | POST | `/crm/v8/{module}/{id}/actions/add_tags` | Add tags to record | | POST | `/crm/v8/{module}/{id}/actions/remove_tags` | Remove tags from record | #### Lead Conversion | Method | Path | Description | |--------|------|-------------| | POST | `/crm/v8/Leads/{id}/convert` | Convert lead to contact/account/deal | #### Emails | Method | Path | Description | |--------|------|-------------| | POST | `/crm/v8/{module}/{id}/send_mail` | Send email from record | | GET | `/crm/v8/{module}/{id}/emails` | Get associated emails | #### COQL (SQL-like queries) | Method | Path | Description | |--------|------|-------------| | POST | `/crm/v8/coql` | Execute query (up to 2000 records) | Example: `SELECT Last_Name, Email FROM Contacts WHERE Lead_Source = 'Web' LIMIT 200` **When to use COQL vs Search:** Use `/search` for simple keyword matching (e.g., find contacts named "Smith"). Use COQL when you need joins, complex WHERE clauses, aggregations, or cross-module queries. COQL is strictly more powerful but consumes more API credits. #### Bulk Operations | Method | Path | Description | |--------|------|-------------| | POST | `/crm/v8/bulk/read` | Create export job (up to 200K records) | | GET | `/crm/v8/bulk/read/{job_id}` | Check job status | | GET | `/crm/v8/bulk/read/{job_id}/result` | Download CSV | #### Users & Settings | Method | Path | Description | |--------|------|-------------| | GET | `/crm/v8/users` | List users | | GET | `/crm/v8/organization` | Get org data | | GET | `/crm/v8/modules` | List all modules | | GET | `/crm/v8/modules/{id}/fields` | List fields in module | | GET | `/crm/v8/settings/roles` | List roles | | GET | `/crm/v8/settings/profiles` | List profiles | ### 6. Notifications/Webhooks **Notification API** (instant data change notifications): ``` POST {api_domain}/crm/v8/actions/watch ``` ```json { "watch": [{ "channel_id": "1000000068001", "events": ["Deals.all"], "notify_url": "https://yourapp.com/webhooks/zoho", "token": "verification_token", "channel_expiry": "2026-02-24T10:00:00+05:30" }] } ``` Events: `{Module}.create`, `{Module}.edit`, `{Module}.delete`, `{Module}.all` Channel expiry: **max 7 days** — must re-register before expiry. The token refresh GenServer should also handle channel re-registration on a schedule. **Verification:** The `token` field above is an arbitrary string you define. Zoho echoes it back in every webhook delivery so you can confirm the payload originated from a channel you created. This is **not** a cryptographic signature — it's a shared secret. For additional security, restrict your webhook endpoint to Zoho's IP ranges (available in Zoho's documentation, varies by data center). ### 7. MCP Tools to Implement ``` zoho_record_list(module), zoho_record_get(module, id), zoho_record_create(module, data) zoho_record_update(module, id, data), zoho_record_delete(module, id) zoho_search(module, criteria) zoho_coql_query(query) zoho_lead_convert(id) zoho_note_create(module, id, text), zoho_notes_list(module, id) zoho_tag_add(module, id, tags), zoho_tag_remove(module, id, tags) zoho_modules_list, zoho_fields_list(module) zoho_users_list ``` **Naming convention:** Zoho tools use a generic `zoho_record_*` pattern with a `module` parameter because Zoho's API is module-generic. This is intentionally different from Pipedrive's entity-specific naming (`pipedrive_deal_get`) — Zoho's 5 record tools replace what would be 25+ entity-specific tools. --- ## Linear **Role in our stack:** Issue tracking and project management for engineering teams. No overlap with other services — Linear is the sole project management tool. ### 1. Account Setup 1. Sign up at [linear.app](https://linear.app) — **Free** plan, no credit card required 2. API access on all plans 3. Plans: Free (250 issues, 2 teams), Basic ($8/user/mo), Business ($12/user/mo), Enterprise (custom) 4. 20% discount on annual billing 5. Startup program: up to 6 months free Basic/Business; non-profits get 75% off ### 2. API Overview Linear uses a **GraphQL API** (not REST). All queries and mutations go through a single endpoint. ``` https://api.linear.app/graphql ``` The schema is explorable via [Apollo Studio](https://studio.apollographql.com/public/Linear-API/schema/reference?variant=current) — no login required. ### 3. API Credentials **Personal API Key** (for internal/single-user use): 1. Log in to Linear 2. Settings > Security & access > API (or user menu > Settings > API) 3. Create a new personal API key 4. Key never expires **Header:** `Authorization: {API_KEY}` (no `Bearer` prefix for personal keys) **OAuth 2.0** (for multi-user apps): 1. Go to Settings > Administration > API 2. Create a new OAuth2 application 3. Set callback URL and select scopes 4. Receive `client_id` and `client_secret` ### 4. OAuth 2.0 Flow **Authorization URL:** ``` https://linear.app/oauth/authorize ?client_id={client_id} &redirect_uri={redirect_uri} &response_type=code &scope=read,write &state={csrf_token} ``` **Token Exchange:** ``` POST https://api.linear.app/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code client_id={client_id} client_secret={client_secret} code={code} redirect_uri={redirect_uri} ``` **Token Refresh:** ``` POST https://api.linear.app/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token client_id={client_id} client_secret={client_secret} refresh_token={refresh_token} ``` **Token Expiry:** - Access token (with refresh tokens): **24 hours** - Access token (legacy, no refresh): **10 years** - Client credentials token: **30 days** - Apps created after Oct 1, 2025 have refresh tokens **enabled by default** - Legacy apps must migrate to refresh tokens by April 1, 2026 **PKCE Support:** Linear supports PKCE for enhanced security (public clients without a server secret): - Add `code_challenge` and `code_challenge_method` (`plain` or `S256`) to authorization URL - Pass `code_verifier` at token exchange - `client_secret` becomes optional with PKCE **Scopes:** | Scope | Description | Required By | |-------|-------------|-------------| | `read` | Default read access to workspace data | All `_list`, `_get` tools | | `write` | Write access (create, update, delete) | All `_create`, `_update`, `_archive` tools | | `issues:create` | Create issues and attachments only | `linear_issue_create` (narrower alternative to `write`) | | `comments:create` | Create comments only | `linear_comment_create` (narrower alternative to `write`) | | `timeSchedule:write` | Manage time schedules | Time schedule tools (if implemented) | | `admin` | Full admin access (use sparingly) | Webhook management, user admin | **Recommended scope for our app:** `read,write` covers all planned MCP tools. Use narrow scopes (`issues:create`, `comments:create`) only if you need to restrict a specific OAuth app to creation-only access. **Actor Modes:** - `actor=user` (default) — actions attributed to the authorizing user - `actor=app` — actions attributed to the OAuth app (service account) ### 5. Rate Limits **Request-based limits (per hour):** | Auth Method | Limit | |-------------|-------| | Personal API Key | 1,500 requests/hour | | OAuth App | 500 requests/hour | | Unauthenticated | 60 requests/hour | **Complexity-based limits (per hour):** | Auth Method | Limit | |-------------|-------| | Personal API Key | 250,000 points | | OAuth App | 200,000 points | | Unauthenticated | 10,000 points | Uses a leaky bucket algorithm with constant token refill. Exceeding limits returns HTTP 429. Rate limit info in response headers. ### 6. Data Hierarchy ``` Organization (Workspace) └── Team ├── Issue │ ├── Sub-issue │ ├── Comment │ ├── Attachment │ └── Label ├── Project │ ├── Project Update │ └── Issues (linked) ├── Cycle (time-boxed sprint) │ └── Issues (linked) ├── Initiative (cross-team objective) │ ├── Initiative Update │ └── Projects (linked) └── Workflow State (status) ``` ### 7. GraphQL Queries All requests are `POST https://api.linear.app/graphql` with `Authorization` header. **Pagination:** Relay-style cursor-based — use `first/after` and `last/before` arguments. Add `includeArchived: true` to include archived resources. **Error handling:** Linear returns 200 for all GraphQL responses. Check the `errors` array in the response body — partial success is possible (some fields resolve while others error). #### Viewer (Authenticated User) ```graphql query { viewer { id, name, email, admin } } ``` #### Teams ```graphql query { teams { nodes { id, name, key, description } } } query { team(id: "team-uuid") { id, name, issues { nodes { id, title, state { name } } } } } ``` #### Issues ```graphql query { issues(filter: { team: { id: { eq: "team-uuid" } }, state: { name: { eq: "In Progress" } } }) { nodes { id, identifier, title, description, priority, state { name }, assignee { name }, project { name }, cycle { name, number }, labels { nodes { name } }, createdAt, updatedAt } } } query { issue(id: "issue-uuid") { id, identifier, title, description, comments { nodes { body, user { name } } } } } ``` #### Projects ```graphql query { projects { nodes { id, name, description, state, progress, startDate, targetDate, teams { nodes { name } } } } } query { project(id: "project-uuid") { id, name, issues { nodes { id, title } } } } ``` #### Cycles ```graphql query { cycles(filter: { team: { id: { eq: "team-uuid" } } }) { nodes { id, number, name, startsAt, endsAt, progress, issues { nodes { id, title } } } } } ``` #### Initiatives ```graphql query { initiatives { nodes { id, name, description, status, targetDate, projects { nodes { id, name } } } } } query { initiative(id: "init-uuid") { id, name, updates { nodes { body, createdAt, user { name } } } } } ``` #### Workflow States ```graphql query { workflowStates(filter: { team: { id: { eq: "team-uuid" } } }) { nodes { id, name, type, position, team { name } } } } ``` State types: `triage`, `backlog`, `unstarted`, `started`, `completed`, `canceled` #### Labels ```graphql query { issueLabels { nodes { id, name, color, parent { name } } } } ``` #### Users ```graphql query { users { nodes { id, name, email, admin, active } } } query { user(id: "user-uuid") { id, name, assignedIssues { nodes { id, title } } } } ``` #### Documents ```graphql query { documents { nodes { id, title, content, project { name }, creator { name } } } } ``` ### 8. GraphQL Mutations #### Issues ```graphql mutation { issueCreate(input: { title: "Bug: login broken" description: "Users can't log in with SSO" teamId: "team-uuid" assigneeId: "user-uuid" priority: 1 stateId: "state-uuid" labelIds: ["label-uuid"] projectId: "project-uuid" cycleId: "cycle-uuid" }) { success, issue { id, identifier, url } } } mutation { issueUpdate(id: "issue-uuid", input: { stateId: "done-state-uuid" priority: 0 }) { success, issue { id, state { name } } } } mutation { issueArchive(id: "issue-uuid") { success } } mutation { issueUnarchive(id: "issue-uuid") { success } } ``` **Archive vs Delete:** `issueArchive` hides the issue but preserves all data — it can be restored with `issueUnarchive`. `issueDelete` is **permanent and irreversible**. Our MCP tools expose `archive` by default. The `issueDelete` mutation exists but is intentionally **not** exposed as an MCP tool to prevent accidental data loss from chat/agent context. #### Comments ```graphql mutation { commentCreate(input: { issueId: "issue-uuid" body: "Fixed in PR #42" }) { success, comment { id, body } } } mutation { commentUpdate(id: "comment-uuid", input: { body: "Updated text" }) { success } } mutation { commentDelete(id: "comment-uuid") { success } } ``` #### Projects ```graphql mutation { projectCreate(input: { name: "Q1 Launch" description: "Launch new features" teamIds: ["team-uuid"] startDate: "2026-01-01" targetDate: "2026-03-31" }) { success, project { id, name, url } } } mutation { projectUpdate(id: "project-uuid", input: { state: "completed" }) { success } } mutation { projectArchive(id: "project-uuid") { success } } ``` #### Cycles ```graphql mutation { cycleCreate(input: { teamId: "team-uuid" name: "Sprint 14" startsAt: "2026-02-17" endsAt: "2026-03-02" }) { success, cycle { id, number } } } mutation { cycleUpdate(id: "cycle-uuid", input: { name: "Sprint 14 — Extended" }) { success } } ``` #### Labels ```graphql mutation { issueLabelCreate(input: { name: "critical" color: "#FF0000" teamId: "team-uuid" }) { success, issueLabel { id, name } } } mutation { issueLabelUpdate(id: "label-uuid", input: { name: "urgent" }) { success } } ``` #### Attachments ```graphql mutation { attachmentCreate(input: { issueId: "issue-uuid" title: "Screenshot" url: "https://example.com/screenshot.png" }) { success, attachment { id } } } ``` #### Documents ```graphql mutation { documentCreate(input: { title: "Architecture Decision Record" content: "## Context\n\nWe need to decide..." projectId: "project-uuid" }) { success, document { id, title, url } } } mutation { documentUpdate(id: "doc-uuid", input: { title: "Updated Title", content: "New content" }) { success } } mutation { documentDelete(id: "doc-uuid") { success } } ``` #### Initiatives ```graphql mutation { initiativeCreate(input: { name: "Q1 Platform Reliability" description: "Improve uptime to 99.9%" targetDate: "2026-03-31" }) { success, initiative { id, name } } } mutation { initiativeUpdate(id: "init-uuid", input: { status: "completed" }) { success } } ``` #### Webhooks (via API) ```graphql mutation { webhookCreate(input: { url: "https://yourapp.com/webhooks/linear" resourceTypes: ["Issue", "Comment", "Project"] allPublicTeams: true }) { success, webhook { id, enabled } } } mutation { webhookDelete(id: "webhook-uuid") { success } } ``` ### 9. Webhooks **Setup:** Settings > Administration > API > New webhook, or via `webhookCreate` mutation (above). **Resource types:** | Resource | Actions | |----------|---------| | Issues | `create`, `update`, `remove` | | Issue comments | `create`, `update`, `remove` | | Issue attachments | `create`, `update`, `remove` | | Issue labels | `create`, `update`, `remove` | | Comment reactions | `create`, `update`, `remove` | | Projects | `create`, `update`, `remove` | | Project updates | `create`, `update`, `remove` | | Documents | `create`, `update`, `remove` | | Initiatives | `create`, `update`, `remove` | | Initiative updates | `create`, `update`, `remove` | | Cycles | `create`, `update`, `remove` | | Customers | `create`, `update`, `remove` | | Customer requests | `create`, `update`, `remove` | | Users | `create`, `update`, `remove` | | Issue SLA | `set`, `highRisk`, `breached` | **Payload fields:** - `action` — `create`, `update`, or `remove` - `type` — entity type (e.g., `Issue`, `Comment`) - `data` — full serialized entity - `updatedFrom` — previous values (on `update` only) - `actor` — user/app/integration that triggered the event - `url` — link to the entity in Linear - `webhookTimestamp` — UNIX ms timestamp - `webhookId` — unique delivery ID **HTTP Headers:** | Header | Value | |--------|-------| | `Linear-Delivery` | UUID identifying this delivery | | `Linear-Event` | Entity type (e.g., `Issue`) | | `Linear-Signature` | HMAC-SHA256 hex signature | | `User-Agent` | `Linear-Webhook` | **Signature Verification:** 1. Get signing secret from webhook detail page 2. Compute HMAC-SHA256 of the **raw request body** using the signing secret 3. Compare against `Linear-Signature` header using constant-time comparison 4. Verify `webhookTimestamp` is within 60 seconds of current time (replay protection) **Important:** Always use raw body bytes — do not stringify parsed JSON, or the signature will mismatch. **IP Allowlist** (for firewall rules): - `35.231.147.226`, `35.243.134.228` - `34.140.253.14`, `34.38.87.206` - `34.134.222.122`, `35.222.25.142` **Delivery & Retry:** - Timeout: 5 seconds - Required response: HTTP 200 - Retries: 3 attempts (after 1 min, 1 hour, 6 hours) - Webhook auto-disabled after repeated failures ### 10. MCP Tools to Implement ``` linear_viewer linear_teams_list, linear_team_get linear_issues_list, linear_issue_get, linear_issue_create, linear_issue_update, linear_issue_archive linear_comments_list, linear_comment_create linear_projects_list, linear_project_get, linear_project_create, linear_project_update linear_cycles_list, linear_cycle_get, linear_cycle_create linear_initiatives_list, linear_initiative_get linear_labels_list, linear_label_create linear_documents_list, linear_document_create, linear_document_update linear_workflow_states_list linear_users_list ``` **Design decision:** `linear_issue_archive` is exposed instead of `linear_issue_delete`. Archive is recoverable; delete is permanent. For an MCP tool invoked by chat/agents, accidental deletion is too risky. If permanent deletion is ever needed, do it through the Linear UI. --- ## Obsidian **Role in our stack:** The connective tissue and unstructured brain of the entire operation. While Pipedrive, Zoho, Linear, and PandaDoc each own structured data within their domains, Obsidian is where **unstructured knowledge lives** — meeting notes, research, decision logs, client context, project briefs, daily journals, and the cross-service narrative that no single tool captures. Every other service generates data; Obsidian gives that data meaning by providing the human context around it. Obsidian runs locally as a desktop app with a markdown vault on disk. Two community plugins expose it to our stack: - **Local REST API** (by Adam Coddington) — turns the vault into a REST API on `localhost` - **obsidian-mcp-server** (by cyanheads) — wraps that REST API as an MCP server, giving AI agents direct vault access ### 1. Setup 1. Download [Obsidian](https://obsidian.md) — free for personal use 2. Create or open a vault (a folder of `.md` files) 3. Install the **Local REST API** community plugin: - Settings > Community plugins > Browse > search "Local REST API" - Install and enable - The plugin generates an **API key** on first enable — copy it - Default ports: **27124** (HTTPS, self-signed cert) or **27123** (HTTP) 4. Install **obsidian-mcp-server** for MCP integration: ```bash npx obsidian-mcp-server ``` Or add to Claude config: ```json { "mcpServers": { "obsidian": { "command": "npx", "args": ["obsidian-mcp-server"], "env": { "OBSIDIAN_API_KEY": "{your_api_key}", "OBSIDIAN_BASE_URL": "https://127.0.0.1:27124", "OBSIDIAN_VERIFY_SSL": "false" } } } } ``` ### 2. Authentication **API Key** — generated by the Local REST API plugin. Passed as a Bearer token: ``` Authorization: Bearer {api_key} ``` - The key never expires and is stored in the plugin's settings - No OAuth flow — this is a local-only service; the key gates access to the vault on the same machine - For our app: store the key in the `oauth_tokens` table with `service: 'obsidian'`, `auth_method: 'api_key'` ### 3. Base URL ``` https://127.0.0.1:27124 (HTTPS with self-signed cert — set verify_ssl: false) http://127.0.0.1:27123 (HTTP, no TLS) ``` The API also exposes its OpenAPI spec at `GET /openapi.yaml` for runtime endpoint discovery. ### 4. Rate Limits No rate limits — this is a local plugin, not a cloud service. The bottleneck is Obsidian's own file I/O. Avoid tight loops that write hundreds of notes per second; batch writes into single PATCH operations where possible. ### 5. Local REST API Endpoints **43 operations** across 6 resource groups. All require `Authorization: Bearer {api_key}` except `GET /`. #### System | Method | Path | Description | |--------|------|-------------| | GET | `/` | Server info + auth status (no auth required) | | GET | `/obsidian-local-rest-api.crt` | Download the self-signed TLS certificate | | GET | `/openapi.yaml` | OpenAPI spec for runtime endpoint discovery | #### Active File (currently open in Obsidian UI) | Method | Path | Description | |--------|------|-------------| | GET | `/active/` | Read the currently-open note | | POST | `/active/` | Append content to the active note | | PUT | `/active/` | Overwrite the active note | | PATCH | `/active/` | Insert content relative to a heading/block/frontmatter field | | DELETE | `/active/` | Delete the active note | #### Vault Files (CRUD by path) | Method | Path | Description | |--------|------|-------------| | GET | `/vault/{path}` | Read a note's content (markdown, JSON, or document map) | | POST | `/vault/{path}` | Append content to a note (creates if new) | | PUT | `/vault/{path}` | Create or overwrite a note | | PATCH | `/vault/{path}` | Insert content at heading/block/frontmatter target | | DELETE | `/vault/{path}` | Delete a note | **PATCH headers** for targeted insertion: - `Operation`: `append`, `prepend`, or `replace` - `Target-Type`: `heading`, `block`, or `frontmatter` - `Target`: the heading text, block reference, or frontmatter key - `Target-Delimiter`: (optional) separator when appending - `Trim-Target-Whitespace`: (optional) trim whitespace around target **Accept headers** control response format: - `text/markdown` — raw markdown - `application/vnd.olrapi.note+json` — JSON with frontmatter parsed, content, and metadata - `application/vnd.olrapi.document-map+json` — structural map of headings/blocks #### Vault Directory Listing | Method | Path | Description | |--------|------|-------------| | GET | `/vault/` | List files/dirs in vault root | | GET | `/vault/{dirPath}/` | List files/dirs in a subdirectory | #### Periodic Notes (daily, weekly, monthly, quarterly, yearly) | Method | Path | Description | |--------|------|-------------| | GET | `/periodic/{period}/` | Get current periodic note | | POST | `/periodic/{period}/` | Append to current periodic note (creates if needed) | | PUT | `/periodic/{period}/` | Overwrite current periodic note | | PATCH | `/periodic/{period}/` | Insert at heading/block in current periodic note | | DELETE | `/periodic/{period}/` | Delete current periodic note | | GET | `/periodic/{period}/{year}/{month}/{day}/` | Get periodic note for specific date | | POST | `/periodic/{period}/{year}/{month}/{day}/` | Append to periodic note for specific date | | PUT | `/periodic/{period}/{year}/{month}/{day}/` | Overwrite periodic note for specific date | | PATCH | `/periodic/{period}/{year}/{month}/{day}/` | Insert in periodic note for specific date | | DELETE | `/periodic/{period}/{year}/{month}/{day}/` | Delete periodic note for specific date | `{period}` values: `daily`, `weekly`, `monthly`, `quarterly`, `yearly` #### Search | Method | Path | Description | |--------|------|-------------| | POST | `/search/simple/` | Full-text search across vault (query + context) | | POST | `/search/` | Advanced search (Dataview DQL or JsonLogic) | **Simple search** uses query params: `?query=keyword&contextLength=100` Returns: `[{filename, matches: [{match: {start, end}, context}], score}]` **Advanced search** uses Content-Type to select query language: - `application/vnd.olrapi.dataview.dql+txt` — Dataview DQL queries - `application/vnd.olrapi.jsonlogic+json` — JsonLogic queries #### Commands | Method | Path | Description | |--------|------|-------------| | GET | `/commands/` | List all available Obsidian commands | | POST | `/commands/{commandId}/` | Execute a command (e.g., toggle theme, open graph view) | #### Open in UI | Method | Path | Description | |--------|------|-------------| | POST | `/open/{filename}` | Open a note in the Obsidian UI (optional `?newLeaf=true`) | ### 6. obsidian-mcp-server Tools The [obsidian-mcp-server](https://github.com/cyanheads/obsidian-mcp-server) wraps the Local REST API as an MCP server, exposing 8 tools that AI agents can call directly: | Tool | Description | Key Parameters | |------|-------------|---------------| | `obsidian_read_note` | Read a note's content and metadata | `filePath`, `format?`, `includeStat?` | | `obsidian_update_note` | Append, prepend, or overwrite a note | `targetType`, `content`, `targetIdentifier?`, `wholeFileMode` | | `obsidian_search_replace` | Find and replace within a note | `targetType`, `replacements[]`, `useRegex?`, `replaceAll?` | | `obsidian_global_search` | Full-text search across the entire vault | `query`, `searchInPath?`, `useRegex?`, `page?`, `pageSize?` | | `obsidian_list_notes` | List notes and subdirectories in a folder | `dirPath`, `fileExtensionFilter?`, `nameRegexFilter?` | | `obsidian_manage_frontmatter` | Get, set, or delete frontmatter keys | `filePath`, `operation` (get/set/delete), `key`, `value?` | | `obsidian_manage_tags` | Add, remove, or list tags on a note | `filePath`, `operation`, `tags` | | `obsidian_delete_note` | Permanently delete a note | `filePath` | **Environment variables:** | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `OBSIDIAN_API_KEY` | Yes | — | API key from Local REST API plugin | | `OBSIDIAN_BASE_URL` | Yes | `http://127.0.0.1:27123` | Local REST API endpoint | | `OBSIDIAN_VERIFY_SSL` | No | `true` | Set `false` for self-signed certs (port 27124) | | `OBSIDIAN_ENABLE_CACHE` | No | `true` | In-memory vault cache for faster reads | | `OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN` | No | `10` | Cache refresh interval (minutes) | ### 7. Vault Structure for Cross-Service Integration This is where Obsidian becomes the connective tissue. The vault is organized so every note links back to the structured data in other services: ``` vault/ CRM/ Deals/ {DealName}.md # Frontmatter: pipedrive_deal_id, zoho_deal_id Clients/ {CompanyName}.md # Frontmatter: pipedrive_org_id, zoho_account_id Meeting Notes/ {YYYY-MM-DD} - {Client}.md # Linked to deal, tagged #meeting Contracts/ {DocumentName}.md # Frontmatter: pandadoc_document_id, status Engineering/ Projects/ {ProjectName}.md # Frontmatter: linear_project_id Issues/ {IssueIdentifier}.md # Frontmatter: linear_issue_id, auto-created on create ADRs/ {NNN} - {Title}.md # Architecture Decision Records, linked to Linear projects Retrospectives/ Sprint {N}.md # Frontmatter: linear_cycle_id Daily/ {YYYY-MM-DD}.md # Periodic daily note — auto-populated via POST /periodic/daily/ Templates/ deal.md # Template for new deal notes client.md # Template for new client notes meeting.md # Template for meeting notes issue.md # Template for Linear issue notes ``` **Frontmatter linking** is the key pattern. Every note that relates to an external entity carries the entity's ID in YAML frontmatter: ```yaml --- pipedrive_deal_id: 12345 zoho_deal_id: "3456789012345678901" pandadoc_document_id: "abc123" status: negotiation value: 50000 tags: [deal, enterprise, q1-2026] --- # Acme Corp — Enterprise License ## Context Met with Sarah (CTO) on 2026-02-10. Interested in annual license... ``` This allows our app to: - **Find context before acting:** Before an agent sends a PandaDoc contract, search the vault for the client note to pull relevant context - **Log actions as notes:** After creating a Linear issue, auto-create a note in `Engineering/Issues/` with the issue details and link - **Build daily digests:** Append to today's daily note when deals close, documents are signed, or sprints end - **Search for institutional knowledge:** "What do we know about Acme Corp?" searches the vault and returns everything — meeting notes, deal history, contract status, engineering context ### 8. Integration Flows #### Pipedrive → Obsidian (deal tracking narrative) **Trigger:** `updated.deal` webhook from Pipedrive **Action:** PATCH the deal's Obsidian note at `CRM/Deals/{DealName}.md` - Update frontmatter `status` and `value` fields via `PATCH /vault/{path}` with `Target-Type: frontmatter` - Append a timestamped changelog entry under a `## History` heading - If no note exists, create from template via `PUT /vault/{path}` **Why Obsidian over Pipedrive notes:** Pipedrive notes are flat text blobs locked inside Pipedrive's UI. Obsidian notes are markdown files with frontmatter, backlinks, tags, and full-text search — they can link to meeting notes, contracts, engineering issues, and anything else in the vault. Pipedrive stores the deal data; Obsidian stores the story around it. #### PandaDoc → Obsidian (contract lifecycle log) **Trigger:** `document_state_changed` webhook from PandaDoc **Action:** Update the contract note at `Contracts/{DocumentName}.md` - Update frontmatter `status` field (draft → sent → viewed → completed) - Append status transition with timestamp under `## Status History` - On `document_completed_pdf_ready`, add a link to the download URL **Why Obsidian over PandaDoc audit trail:** PandaDoc has an audit trail API, but it's per-document and not searchable across contracts. Obsidian lets you search "which contracts completed this quarter" or "what's still pending for Acme Corp" across the entire vault. #### Linear → Obsidian (engineering knowledge base) **Trigger:** `Issue.create` / `Issue.update` webhooks from Linear, or manual agent action **Action:** - On issue create: generate note at `Engineering/Issues/{Identifier}.md` from template with frontmatter `linear_issue_id`, `linear_project_id`, `assignee`, `priority` - On sprint end: auto-create retrospective note at `Engineering/Retrospectives/Sprint {N}.md` with links to all issues from the cycle - Architecture Decision Records (ADRs) live in `Engineering/ADRs/` and link to Linear project IDs **Why Obsidian over Linear documents:** Linear has a Documents feature, but it's scoped to the workspace and lacks backlinks, tags, or cross-service linking. Obsidian ADRs can reference CRM deals ("we're building this because Acme Corp needs it"), contracts ("covered under SOW #4"), and other issues. Linear stores the work; Obsidian stores the reasoning. #### Zoho → Obsidian (reporting context) **Trigger:** Scheduled job or manual agent action **Action:** Run a COQL query in Zoho, format results as a markdown table, and write to `CRM/Reports/{ReportName}.md` - Useful for weekly pipeline snapshots, lead source analysis, or custom dashboards that live in the vault alongside narrative context **Why Obsidian over Zoho reports:** Zoho has a powerful analytics API, but its reports are locked inside Zoho's UI. Writing report snapshots to Obsidian means they're searchable alongside meeting notes, deal context, and engineering decisions. A weekly pipeline report sitting next to the meeting notes from that week tells a richer story. #### Obsidian → All Services (context retrieval) **Trigger:** Agent needs context before performing an action in any service **Action:** Search the vault using `POST /search/simple/?query=Acme+Corp` - Before creating a PandaDoc contract, search for the client note and pull deal terms - Before updating a Linear issue, search for related ADRs or meeting notes - Before a sales call, search for all notes tagged `#acme-corp` to build a briefing This is the core value of Obsidian as connective tissue: **every service action can be informed by the full context of the vault**. ### 9. MCP Tools to Implement For our Elixir app's own MCP server (in `my_mcp_server.ex`), expose these tools that wrap the Local REST API directly: ``` obsidian_note_read(path) obsidian_note_write(path, content, mode) -- mode: create | append | overwrite obsidian_note_patch(path, target, content) -- targeted insertion at heading/block/frontmatter obsidian_note_delete(path) obsidian_vault_list(dir_path) obsidian_search(query) -- simple full-text search obsidian_search_advanced(query, type) -- Dataview DQL or JsonLogic obsidian_periodic_read(period, date?) obsidian_periodic_write(period, content, date?) obsidian_commands_list obsidian_command_run(command_id) obsidian_frontmatter_get(path, key?) obsidian_frontmatter_set(path, key, value) obsidian_tags_list(path) obsidian_tags_add(path, tags) obsidian_tags_remove(path, tags) ``` **Why wrap the Local REST API in our own MCP tools instead of using obsidian-mcp-server directly?** The obsidian-mcp-server is a standalone Node.js process that Claude Desktop or other MCP clients connect to. Our Elixir app is its own MCP server — by wrapping the Local REST API in Elixir, we get: - Permission gating via AccessControl (the obsidian-mcp-server has no permission model) - Cross-service workflows in a single MCP call (e.g., "create a deal note" that reads from Pipedrive, writes to Obsidian, and links the IDs) - Consistent error handling and logging with the rest of our stack - No dependency on a separate Node.js process in production The obsidian-mcp-server remains useful for **development and direct Claude Desktop use** — it gives Claude immediate vault access without our app running. --- ## Service Overlap & Selection Guide ### Obsidian as Connective Tissue Every structured service has a knowledge gap — Pipedrive knows deal amounts but not the conversation that led to them. Linear knows issue status but not the architectural reasoning behind the approach. PandaDoc knows contract status but not the negotiation history. Obsidian fills these gaps. | Service | Stores (structured) | Obsidian Adds (unstructured) | |---------|---------------------|------------------------------| | Pipedrive | Deal stage, value, contact fields | Meeting notes, relationship context, negotiation strategy | | PandaDoc | Document status, signatures, audit trail | Contract rationale, approval discussions, term negotiations | | Zoho | Records across modules, COQL query results | Report narratives, data interpretations, trend analysis | | Linear | Issue status, assignees, sprint progress | ADRs, design docs, retrospectives, cross-project context | **The pattern:** Structured services are the system of record for their domain data. Obsidian is the system of record for the **narrative context** that connects them. Never duplicate structured data into Obsidian as the source of truth — instead, link to it via frontmatter IDs and let Obsidian hold the human-written context around it. ### Notes vs Notes: Which Service's Notes to Use Three services have their own "notes" feature. Use each for a specific purpose: | Service Notes Feature | Use For | Don't Use For | |----------------------|---------|---------------| | Pipedrive Notes (v1) | Quick inline annotations on deals visible to the sales team in Pipedrive's UI | Anything you'd want to search across contexts, link to other notes, or access outside Pipedrive | | Linear Documents | Lightweight specs or RFCs that only need to be visible within a Linear project | Anything that references CRM data, contracts, or cross-team knowledge | | Obsidian Notes | Everything else — meeting notes, decision logs, research, cross-service context, institutional knowledge | Structured data that belongs in a CRM field or issue tracker property | **Rule of thumb:** If a note only makes sense inside one service's UI, keep it there. If it connects two or more services or needs to be found by searching "what do we know about X", it belongs in Obsidian. ### CRM: Pipedrive vs Zoho Both are CRMs with overlapping data models. Rather than duplicate every operation, each service owns the use cases where it is stronger: | Capability | Preferred | Why | |------------|-----------|-----| | **Sales pipeline & deals** | Pipedrive | Purpose-built for pipeline management. Visual deal stages, merge deals, deal followers, and deal-product linking are first-class features. Zoho can manage deals but its generic module approach adds friction for pipeline-specific workflows. | | **Contacts & companies** | Pipedrive (primary), Zoho (sync) | Pipedrive persons/organizations are the primary contact store. Pipedrive's dedicated search endpoints (`/persons/search`, `/organizations/search`) return richer, typed results than Zoho's generic `/search`. Zoho contacts should be synced from Pipedrive, not managed independently. | | **Leads & conversion** | Pipedrive | Simpler model — leads live in a separate queue and convert to deals with one endpoint. Zoho's lead conversion creates contacts + accounts + deals in one call, which is more powerful but also more complex and error-prone if you don't need all three. | | **Multi-module data & custom modules** | Zoho | Zoho's generic module API means one set of CRUD operations works for Leads, Contacts, Deals, Products, Invoices, and any custom module. 5 MCP tools replace 25+. If you need to query across arbitrary record types or build custom CRM modules, Zoho is the only option. | | **Bulk operations** | Zoho | Export up to 200K records per job. Pipedrive has no bulk export API. | | **SQL-like queries** | Zoho | COQL lets you write `SELECT ... FROM ... WHERE ... JOIN` across modules. Pipedrive has no equivalent — only per-entity filter params. | | **Activities & calendar** | Pipedrive | Simpler activity model with types (call, meeting, email, task). Zoho splits these across Events, Tasks, and Calls modules. | | **Reporting & analytics** | Zoho | Built-in analytics API with dashboards. Pipedrive reporting is weaker and mostly UI-only. | **Practical recommendation:** Use Pipedrive as the primary CRM interface for sales teams (deals, contacts, pipeline). Use Zoho for back-office operations that need bulk data, COQL queries, or custom modules. Keep contacts in sync Pipedrive → Zoho (one-way) using webhooks. ### Contacts & Companies Contacts exist in three services: | Service | Entity | Role | |---------|--------|------| | Pipedrive | Persons + Organizations | **Source of truth** for sales contacts. Created/updated by sales team. | | PandaDoc | Contacts | **Read-only consumer.** PandaDoc contacts are auto-created when documents are sent to recipients. Use PandaDoc's Linked Objects API to connect documents back to Pipedrive persons/deals. Do not manage contacts directly in PandaDoc. | | Zoho | Contacts + Accounts | **Synced from Pipedrive.** Use Pipedrive `updated.person` / `updated.organization` webhooks to push changes to Zoho for reporting and bulk operations. | **Sync strategy:** Pipedrive is the write master. Zoho receives synced copies. PandaDoc references by linking. No service writes contacts back to another. ### Search Strategy | Need | Use | Why | |------|-----|-----| | "Find a person named Smith" | `pipedrive_person_get` or `pipedrive_search` | Pipedrive's typed search returns richer contact data than Zoho's generic search | | "Find all deals > $50K in pipeline" | `pipedrive_deals_list` with filters | Pipedrive's deal-specific filters are more ergonomic than Zoho's generic module search | | "Complex cross-module query" | `zoho_coql_query` | Only Zoho supports SQL-like queries across modules. Example: `SELECT Deal_Name, Contact.Email FROM Deals WHERE Amount > 50000` | | "Bulk export all contacts for analysis" | `zoho_record_list` or Zoho Bulk API | Zoho returns up to 200 records/page (vs Pipedrive's default 100) and has a dedicated bulk export API for large datasets | | "Find issues assigned to me" | `linear_issues_list` with filter | Linear's GraphQL filters are expressive and type-safe | ### Webhook Verification Summary All four services support webhook verification, but methods differ: | Service | Method | Header | Secret Source | |---------|--------|--------|---------------| | Pipedrive | HMAC-SHA256 | `X-Pipedrive-Signature` | OAuth app `client_secret` | | PandaDoc | HMAC-SHA256 | `X-PandaDoc-Signature` | Shared key from webhook config | | Zoho | Shared token echo | N/A (in JSON body) | `token` field you define at registration | | Linear | HMAC-SHA256 | `Linear-Signature` | Signing secret from webhook detail page | Obsidian has no webhooks — it's a local app, not a cloud service. Our app polls or reacts to vault changes via the REST API. **Implementation:** Create a shared `WebhookVerifier` module with per-service verify functions. All webhook routes should reject unverified payloads with 401. Zoho's verification is the weakest (shared token, not cryptographic) — supplement with IP allowlisting if possible. ### Error Handling Patterns | Service | HTTP Codes | Pagination | Rate Limit Signal | |---------|-----------|------------|-------------------| | Pipedrive | Standard REST (200, 400, 401, 403, 404, 429) | Cursor-based (`additional_data.pagination.next_cursor`) | `X-RateLimit-Remaining` header | | PandaDoc | Standard REST | Page/count (`page`, `count` params, `results` array) | `429 Too Many Requests` | | Zoho | Standard REST + custom codes in body (`code: "INVALID_TOKEN"`) | Page/offset (`page`, `per_page`, `info.more_records`) | `429` + `X-RATELIMIT-REMAINING` header | | Linear | Always 200 (GraphQL). Check `errors` array in body. | Relay cursors (`first/after`, `pageInfo.hasNextPage/endCursor`) | `429` + complexity budget in response extensions | | Obsidian | Standard REST (200, 204, 400, 404). Local only. | Directory listing returns full arrays (no pagination) | No rate limits (local) | **Implementation:** The `ServiceClient` must normalize these into a consistent `{:ok, data, pagination}` | `{:error, reason}` response format. Each service module implements its own `parse_response/1` and `next_page/1` functions. Obsidian has no pagination — vault listing returns all files in a directory. For large vaults, use search instead of listing. --- ## Implementation Plan ### Phase 1: Shared Infrastructure 1. **OAuth token table** — Create `oauth_tokens` Sqler table: ```sql CREATE TABLE oauth_tokens ( id INTEGER PRIMARY KEY, updated_at INTEGER, user_id INTEGER NOT NULL, service TEXT NOT NULL, -- 'pipedrive', 'pandadoc', 'zoho', 'linear', 'obsidian' auth_method TEXT NOT NULL, -- 'oauth', 'api_key', 'personal_token' access_token TEXT NOT NULL, -- encrypted refresh_token TEXT, -- encrypted (NULL for api_key and personal_token) scope TEXT, -- granted scopes (e.g., 'read,write' or 'deals:full,contacts:full') expires_at INTEGER, -- Unix ms (NULL for non-expiring tokens) extra TEXT -- JSON: {api_domain, company_domain, header_format, data_center, ...} ) ``` The `auth_method` column determines how the token is sent: - `oauth` → look up header format from service config (Bearer, Zoho-oauthtoken, etc.) - `api_key` → `Authorization: API-Key {token}` (PandaDoc) - `personal_token` → `Authorization: {token}` (Linear) or query param (Pipedrive) The `extra` JSON column stores service-specific metadata: - Zoho: `{"api_domain": "https://www.zohoapis.com", "data_center": "US"}` - Pipedrive: `{"company_domain": "mycompany"}` - Linear: `{"actor_mode": "user"}` - Obsidian: `{"base_url": "https://127.0.0.1:27124", "verify_ssl": false}` 2. **Token refresh GenServer** — Periodically checks `expires_at` and refreshes tokens before expiry. Critical refresh intervals: - Zoho: refresh every ~50 minutes (1-hour token) - Linear: refresh every ~22 hours (24-hour token) - Pipedrive: refresh every ~50 minutes (1-hour token) - PandaDoc: no proactive refresh needed (~1-year token), but handle 401 reactively - Obsidian: no refresh needed (local API key, never expires) - Also handles Zoho webhook channel re-registration (7-day expiry) 3. **ServiceClient** — Shared module with two interfaces: ```elixir defmodule ServiceClient do # For REST APIs (Pipedrive, PandaDoc, Zoho, Obsidian) def rest(service, user_id, method, path, opts \\ []) # For GraphQL APIs (Linear) def graphql(service, user_id, query, variables \\ %{}) # Both handle: # - Token lookup from oauth_tokens # - Auth header injection (format varies by service+auth_method) # - 401 → refresh token + retry once # - 429 → exponential backoff with jitter # - Response normalization to {:ok, data, pagination} | {:error, reason} end ``` 4. **WebhookVerifier** — Shared module for per-service verification: ```elixir defmodule WebhookVerifier do def verify(:pipedrive, conn) # HMAC-SHA256 via client_secret def verify(:pandadoc, conn) # HMAC-SHA256 via shared key def verify(:zoho, conn) # Token echo check def verify(:linear, conn) # HMAC-SHA256 via signing secret + timestamp check end ``` 5. **Webhook routes** — Add to router: ``` POST /webhooks/pipedrive POST /webhooks/pandadoc POST /webhooks/zoho POST /webhooks/linear ``` All routes call `WebhookVerifier.verify/2` before processing. Reject with 401 on failure. Obsidian has no webhooks (local app) — integration is pull-based via REST API calls. 6. **Permission keys** — One per service in AccessControl ### Phase 2: Service Modules (one at a time) For each service: 1. Create `lib/{service}.ex` with: - API wrapper functions matching the MCP tools list above - For REST services: functions that call `ServiceClient.rest/5` - For Linear: functions that call `ServiceClient.graphql/4` with typed query strings - OAuth connect/disconnect functions - No `setup_database/1` needed — these are pure API proxies with no local state (token storage is handled by the shared `oauth_tokens` table) - Exception: `obsidian.ex` may optionally maintain a local index of vault note paths for faster cross-referencing with external entity IDs 2. Create `lib/{service}_rest.ex` with REST endpoint handlers for our app's API 3. Add MCP tool definitions and handlers to `my_mcp_server.ex` 4. Add REST routes to `my_mcp_server_router.ex` 5. Add webhook handler for incoming events ### Phase 3: Web UI - OAuth connect buttons on a `/settings/integrations` page - Service-specific dashboards (e.g., `/pipedrive/deals`, `/linear/issues`) ### Priority Order 1. **Obsidian** — local REST API with static API key (zero auth complexity), immediate value as the knowledge layer. Implement first because every subsequent service integration benefits from being able to log context to the vault. No OAuth, no token refresh, no webhooks — just HTTP calls to localhost. 2. **Linear** — personal API key (simplest cloud auth, no expiry), GraphQL API, immediate value for project/issue tracking. Once Obsidian is live, new issues can auto-generate vault notes from day one. 3. **Pipedrive** — OAuth with 1-hour access tokens but 90-day rolling refresh tokens. Core CRM data for sales pipeline. Deal and contact notes flow into Obsidian vault. 4. **PandaDoc** — API key for sandbox (instant start), ~1-year OAuth tokens for production. Document workflows. Contract lifecycle logged to vault. 5. **Zoho** — most complex: 1-hour tokens, data center routing, non-standard auth header (`Zoho-oauthtoken`), 7-day webhook channel expiry. Implement last once token refresh GenServer is battle-tested. COQL report snapshots written to vault.