{
  "application": {
    "universalIdentifier": "79738d7c-6b48-47fd-949f-de6510e3137d",
    "displayName": "Granola",
    "description": "Sync Granola meeting notes into Twenty: create a Note linked to each participant Person, their Company and related Opportunities, plus a CallRecording carrying the AI summary and transcript, linked to the matching CalendarEvent. Polls the Granola REST API on a cron and backfills recent meetings on install.",
    "logo": "public/twenty-granola.svg",
    "author": "Excelium Tech",
    "category": "Productivity",
    "galleryImages": [
      "public/gallery/account-notes.png",
      "public/gallery/meeting-summary.png"
    ],
    "websiteUrl": "https://docs.twenty.com/developers/extend/apps/getting-started",
    "termsUrl": "https://www.twenty.com/terms",
    "emailSupport": "contact@twenty.com",
    "issueReportUrl": "https://github.com/twentyhq/twenty/issues",
    "defaultRoleUniversalIdentifier": "43e9f830-3b8e-4af6-ae29-1520d86f6903",
    "aboutDescription": "# Granola for Twenty\n\nSync [Granola](https://granola.ai) meeting notes into your Twenty CRM. For every\nmeeting, this app creates a `Note` on each participant `Person` (so the meeting\nis visible on Person, Company, and Opportunity pages) and a `CallRecording`\ncarrying Granola's AI summary and transcript, linked to the matching\n`CalendarEvent` — searchable, in context, and ready for AI agents and workflows\nto act on.\n\n## What this app does\n\n1. Granola records and summarizes your meeting.\n2. On a schedule (every 15 minutes) this app polls the Granola REST API for\n   notes updated since the last window, and on install it backfills the last 30\n   days.\n3. For each note it fetches the full detail (summary, transcript, attendees,\n   calendar event) from `GET /v1/notes/{id}?include=transcript`.\n4. It resolves attendee emails to existing `Person` records and upserts:\n   - a `Note` (title + a link back to Granola + the AI summary markdown) with one\n     `NoteTarget` per resolved participant, and\n   - a `CallRecording` (one per Granola meeting) with the AI summary and the\n     transcript, linked to the matching `CalendarEvent` when one is found.\n\nAll writes are idempotent: deterministic ids mean re-syncing a meeting updates\nthe same records instead of duplicating them.\n\n## Calendar event matching\n\nGranola notes carry a `calendar_event` object. The app matches it to a Twenty\n`CalendarEvent` with two strategies (mirroring the platform's calendar sync):\n\n1. **External event id** — the note's provider event id\n   (`external_id` / `google_calendar_event_id`) is matched against\n   `CalendarChannelEventAssociation.eventExternalId`.\n2. **iCalUID** — the note's `ical_uid` is matched against `CalendarEvent.iCalUid`.\n\nIf no calendar event matches (the meeting was never on a synced calendar, or\ncalendar sync is not configured in Twenty), the `CallRecording` is still created,\njust without a linked calendar event, and the `Note` still delivers Person-page\nvisibility.\n\n## Transcript\n\nGranola's transcript is a flat string segmented only by coarse audio-source\nmarkers (`Microphone:` for the local mic, `Speaker:` for remote audio) — there\nare **no timestamps and no real attendee names**. The app parses it into ordered\nspeaker turns at write time and renders a simple scrollable list on the calendar\nevent's **Transcript** tab. The AI **Summary** tab is the primary surface.\n\n## Setup\n\n| Step | Where |\n| ---- | ----- |\n| 1. Get a Granola **Business-plan** API key | https://granola.ai → API settings |\n| 2. Install the **Granola** app in Twenty | Settings → Apps → Granola → Install |\n| 3. Paste the key into the `GRANOLA_API_KEY` application variable | Twenty app settings |\n\nOn install the app backfills the last 30 days; after that the cron keeps it up to\ndate.\n\n## Configuration\n\n| Application variable | Purpose |\n| -------------------- | ------- |\n| `GRANOLA_API_KEY` (secret) | Bearer token for `https://public-api.granola.ai/v1`. Used to list notes and fetch each note's transcript, summary, attendees, and calendar event. |\n\n## Notes and limitations\n\n- **Polling, not webhooks.** Granola does not expose webhooks, so completeness\n  relies on an incremental overlap window plus a once-daily 24h re-sweep. A note\n  updated only during a longer outage is picked up by the next daily sweep or a\n  manual re-install/backfill.\n- **Plain transcript.** No diarization or timestamps (Granola limitation).\n- **Person matching by email.** A participant only gets a `NoteTarget` once a\n  matching `Person` exists in Twenty (by primary email). Unmatched participants\n  are linked on a later run after Twenty's own contact sync creates the Person.\n- **No custom fields in v1.** The Granola meeting id is stored in the standard\n  `CallRecording.externalRecordingId`; the Granola web URL is carried in the Note\n  body.\n",
    "yarnLockChecksum": "3f44613725d7a6d01337abf8e6e2fb01",
    "packageJsonChecksum": "8a53e82002f940483d88c2e84d8a2725",
    "requiredServerVersionRange": ">=2.20.0",
    "postInstallLogicFunction": {
      "universalIdentifier": "f50358d3-3e94-4933-bbed-f94b05311b7f",
      "shouldRunOnVersionUpgrade": true,
      "shouldRunSynchronously": false
    }
  },
  "objects": [
    {
      "universalIdentifier": "fbaf8a1c-c53e-4a86-bfbf-31b773c1a838",
      "nameSingular": "granolaConnection",
      "namePlural": "granolaConnections",
      "labelSingular": "Granola connection",
      "labelPlural": "Granola connections",
      "description": "A workspace member's personal Granola API key and last-sync status. One row per member; the Granola sync enumerates active connections and pulls each member's meetings with their own key.",
      "icon": "IconPlugConnected",
      "isSearchable": false,
      "isUICreatable": false,
      "isUIEditable": false,
      "labelIdentifierFieldMetadataUniversalIdentifier": "32c08eee-f433-4027-bf7e-f36f2f6b49f6",
      "fields": [
        {
          "universalIdentifier": "32c08eee-f433-4027-bf7e-f36f2f6b49f6",
          "type": "TEXT",
          "name": "name",
          "label": "Name",
          "icon": "IconUser",
          "defaultValue": "''"
        },
        {
          "universalIdentifier": "6126dadc-9462-4901-b8d0-b9cdf25abd83",
          "type": "TEXT",
          "name": "workspaceMemberId",
          "label": "Workspace member ID",
          "icon": "IconId",
          "defaultValue": "''"
        },
        {
          "universalIdentifier": "ff502f4b-a889-46fc-8d41-108e78e10827",
          "type": "TEXT",
          "name": "apiKey",
          "label": "API key",
          "icon": "IconKey",
          "defaultValue": "''"
        },
        {
          "universalIdentifier": "4bbb3104-122c-45d3-a278-d8e72cff031d",
          "type": "BOOLEAN",
          "name": "isActive",
          "label": "Active",
          "icon": "IconToggleRight",
          "defaultValue": true
        },
        {
          "universalIdentifier": "2321678a-f090-4688-bf91-4b57f83a33d8",
          "type": "DATE_TIME",
          "name": "lastSyncedAt",
          "label": "Last synced at",
          "icon": "IconClock",
          "isNullable": true
        },
        {
          "universalIdentifier": "ba88113f-1639-4e8e-af39-da18a695b37f",
          "type": "TEXT",
          "name": "lastSyncStatus",
          "label": "Last sync status",
          "icon": "IconActivityHeartbeat",
          "isNullable": true
        },
        {
          "universalIdentifier": "59cea986-0a5b-42e1-ae49-a1a8f8cb982f",
          "type": "TEXT",
          "name": "lastSyncError",
          "label": "Last sync error",
          "icon": "IconAlertTriangle",
          "isNullable": true
        }
      ]
    }
  ],
  "fields": [],
  "indexes": [],
  "permissionFlags": [],
  "roles": [
    {
      "universalIdentifier": "43e9f830-3b8e-4af6-ae29-1520d86f6903",
      "label": "Granola sync role",
      "description": "Reads CalendarEvent and CalendarChannelEventAssociation to locate the meeting matching a Granola note, resolves participant Persons by email, and writes the synced meeting into a Note (with NoteTargets on each participant) and a CallRecording carrying the AI summary and transcript linked to the matched CalendarEvent.",
      "canReadAllObjectRecords": false,
      "canUpdateAllObjectRecords": false,
      "canSoftDeleteAllObjectRecords": false,
      "canDestroyAllObjectRecords": false,
      "canUpdateAllSettings": false,
      "canBeAssignedToAgents": false,
      "canBeAssignedToUsers": false,
      "canBeAssignedToApiKeys": false,
      "objectPermissions": [
        {
          "objectUniversalIdentifier": "fbaf8a1c-c53e-4a86-bfbf-31b773c1a838",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": true,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "ca07f83d-37eb-5532-8049-ece5e47c87dd"
        },
        {
          "objectUniversalIdentifier": "20202020-3319-4234-a34c-82d5c0e881a6",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": false,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "c099bb46-60a6-505c-bc33-4b8aa06bf11e"
        },
        {
          "objectUniversalIdentifier": "20202020-8f1d-4eef-9f85-0d1965e27221",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": false,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "5a115e29-9c8f-5246-9498-731a2b440a4e"
        },
        {
          "objectUniversalIdentifier": "20202020-491b-4aaa-9825-afd1bae6ae00",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": false,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "6170ce93-0cbe-525f-9d33-92f3fd4df623"
        },
        {
          "objectUniversalIdentifier": "ce19efb9-710f-45b2-b141-473abbeea60b",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": true,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "6794aaaa-821a-5910-8e5a-cd17ea3b70d6"
        },
        {
          "objectUniversalIdentifier": "20202020-0b00-45cd-b6f6-6cd806fc6804",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": true,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "f884a709-3d11-5bf6-a713-973c191d0725"
        },
        {
          "objectUniversalIdentifier": "20202020-fff0-4b44-be82-bda313884400",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": true,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "c6e28ef0-ce56-5c1a-b9ee-4b9558667cc7"
        },
        {
          "objectUniversalIdentifier": "20202020-e674-48e5-a542-72570eee7213",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": false,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "6fcb7055-2dc7-5347-b06a-b36c596cbddf"
        },
        {
          "objectUniversalIdentifier": "20202020-b374-4779-a561-80086cb2e17f",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": false,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "7292a5fe-c086-5efe-8444-7dba457c6c8c"
        },
        {
          "objectUniversalIdentifier": "20202020-9549-49dd-b2b2-883999db8938",
          "canReadObjectRecords": true,
          "canUpdateObjectRecords": false,
          "canSoftDeleteObjectRecords": false,
          "canDestroyObjectRecords": false,
          "universalIdentifier": "b676c0ad-ea84-5e6f-ab42-cc41a31a4bbd"
        }
      ],
      "fieldPermissions": [],
      "permissionFlagUniversalIdentifiers": [],
      "rowLevelPermissionPredicateGroups": [],
      "rowLevelPermissionPredicates": []
    }
  ],
  "skills": [],
  "agents": [],
  "connectionProviders": [],
  "logicFunctions": [
    {
      "universalIdentifier": "04a667fa-07c7-46fa-a589-56f47a9a4131",
      "name": "granola-set-key",
      "description": "Stores the invoking member's personal Granola API key after validating it against the Granola API.",
      "timeoutSeconds": 60,
      "httpRouteTriggerSettings": {
        "path": "/granola/connection/set-key",
        "httpMethod": "POST",
        "isAuthRequired": true
      },
      "handlerName": "default.config.handler",
      "sourceHandlerPath": "src/logic-functions/set-granola-key.function.ts",
      "builtHandlerPath": "src/logic-functions/set-granola-key.function.mjs",
      "builtHandlerChecksum": "55adbd25da2eacb3ebed54a8d0c32837"
    },
    {
      "universalIdentifier": "6cc6511f-5b9c-4bd2-b010-75b9ece3d937",
      "name": "granola-disconnect",
      "description": "Clears and deactivates the invoking member's Granola connection so their meetings stop syncing.",
      "timeoutSeconds": 30,
      "httpRouteTriggerSettings": {
        "path": "/granola/connection/disconnect",
        "httpMethod": "POST",
        "isAuthRequired": true
      },
      "handlerName": "default.config.handler",
      "sourceHandlerPath": "src/logic-functions/disconnect-granola.function.ts",
      "builtHandlerPath": "src/logic-functions/disconnect-granola.function.mjs",
      "builtHandlerChecksum": "270a7130657f38ae761bfb03c162bd7f"
    },
    {
      "universalIdentifier": "73535e55-5400-41ce-bd8e-65840e4224ea",
      "name": "granola-get-connection",
      "description": "Returns the invoking member's Granola connection status (connected, last sync) without exposing the stored key.",
      "timeoutSeconds": 30,
      "httpRouteTriggerSettings": {
        "path": "/granola/connection/get",
        "httpMethod": "POST",
        "isAuthRequired": true
      },
      "handlerName": "default.config.handler",
      "sourceHandlerPath": "src/logic-functions/get-granola-connection.function.ts",
      "builtHandlerPath": "src/logic-functions/get-granola-connection.function.mjs",
      "builtHandlerChecksum": "37727f7b417bf5246a23ff6e19bac2a0"
    },
    {
      "universalIdentifier": "e3b3aa22-b36a-4c5b-a02e-4d36e43524d4",
      "name": "granola-sync",
      "description": "Polls the Granola REST API on a schedule for notes updated within the overlap window (and once per day re-sweeps the last 24h), upserting a Note + NoteTargets on participants and a CallRecording with the summary and transcript for each meeting.",
      "timeoutSeconds": 300,
      "cronTriggerSettings": {
        "pattern": "*/15 * * * *"
      },
      "handlerName": "default.config.handler",
      "sourceHandlerPath": "src/logic-functions/granola-sync.ts",
      "builtHandlerPath": "src/logic-functions/granola-sync.mjs",
      "builtHandlerChecksum": "1b2c9c063ac36375397631bf5b6b9d65"
    },
    {
      "universalIdentifier": "f50358d3-3e94-4933-bbed-f94b05311b7f",
      "name": "granola-backfill",
      "description": "On install, backfills the last 30 days of Granola meetings: upserts a Note + NoteTargets on participants and a CallRecording with the summary and transcript for each meeting.",
      "timeoutSeconds": 300,
      "shouldRunOnVersionUpgrade": true,
      "handlerName": "default.config.handler",
      "sourceHandlerPath": "src/logic-functions/granola-backfill.ts",
      "builtHandlerPath": "src/logic-functions/granola-backfill.mjs",
      "builtHandlerChecksum": "291e5b1835574a9306db6e80b310231a"
    }
  ],
  "frontComponents": [
    {
      "universalIdentifier": "0233043a-8284-495c-98cf-9b2380ee063a",
      "name": "calendar-event-summary",
      "description": "Read-only Granola AI summary viewer for the calendar event record page.",
      "componentName": "CalendarEventSummary",
      "sourceComponentPath": "src/front-components/calendar-event-summary.front-component.tsx",
      "builtComponentPath": "src/front-components/calendar-event-summary.front-component.mjs",
      "builtComponentChecksum": "b2e46afcc8aa175154873bbd2229213c2b5fca4df69ac5ccc8780e11a1dc04ac",
      "isHeadless": false,
      "usesSdkClient": true
    },
    {
      "universalIdentifier": "bea81361-5df7-4c1f-984d-73274957bd5d",
      "name": "calendar-event-transcript",
      "description": "Read-only Granola meeting transcript viewer (speaker turns) for the calendar event record page.",
      "componentName": "CalendarEventTranscript",
      "sourceComponentPath": "src/front-components/calendar-event-transcript.front-component.tsx",
      "builtComponentPath": "src/front-components/calendar-event-transcript.front-component.mjs",
      "builtComponentChecksum": "bdd94765870275d9b476a6a8419f968c10637de316de545c7a9510cdc519a342",
      "isHeadless": false,
      "usesSdkClient": true
    },
    {
      "universalIdentifier": "cf44e138-08ba-42bf-8144-a130d3f7448d",
      "name": "granola-connect",
      "description": "Side panel where a workspace member sets or clears their personal Granola API key.",
      "componentName": "GranolaConnect",
      "sourceComponentPath": "src/front-components/granola-connect.front-component.tsx",
      "builtComponentPath": "src/front-components/granola-connect.front-component.mjs",
      "builtComponentChecksum": "54f18fa3f35b3f4e8ddf3de0b17d75663cc6edd0503098caffe02dce5e6ae8b3",
      "isHeadless": false,
      "usesSdkClient": false
    }
  ],
  "publicAssets": [
    {
      "filePath": "public/gallery/account-notes.png",
      "fileName": "account-notes.png",
      "fileType": "png",
      "checksum": "022c4faeb43ce8cd203533e872d7d355"
    },
    {
      "filePath": "public/gallery/meeting-summary.png",
      "fileName": "meeting-summary.png",
      "fileType": "png",
      "checksum": "78a0c5baabcc50f6a8e08e452065f084"
    },
    {
      "filePath": "public/twenty-granola.svg",
      "fileName": "twenty-granola.svg",
      "fileType": "svg",
      "checksum": "1ea7f3c0d7278002de1f4d9ee8260795"
    }
  ],
  "views": [],
  "viewFields": [],
  "navigationMenuItems": [],
  "pageLayouts": [],
  "pageLayoutTabs": [
    {
      "universalIdentifier": "5a57429d-6ade-441d-8c35-512616793a2f",
      "title": "Transcript",
      "position": 17,
      "icon": "IconMicrophone",
      "layoutMode": "CANVAS",
      "pageLayoutUniversalIdentifier": "b9b10e40-9ce2-4704-8ac6-c6e92e2563c1",
      "widgets": [
        {
          "universalIdentifier": "8768103e-b059-44ee-b72e-bc164b954511",
          "title": "Transcript",
          "type": "FRONT_COMPONENT",
          "configuration": {
            "configurationType": "FRONT_COMPONENT",
            "frontComponentUniversalIdentifier": "bea81361-5df7-4c1f-984d-73274957bd5d"
          }
        }
      ]
    },
    {
      "universalIdentifier": "73bd93e8-379e-4f04-a7cf-72965f1c1984",
      "title": "Summary",
      "position": 16,
      "icon": "IconFileText",
      "layoutMode": "CANVAS",
      "pageLayoutUniversalIdentifier": "b9b10e40-9ce2-4704-8ac6-c6e92e2563c1",
      "widgets": [
        {
          "universalIdentifier": "04780500-8a23-43de-b8bd-04e0e5f6dac8",
          "title": "Summary",
          "type": "FRONT_COMPONENT",
          "configuration": {
            "configurationType": "FRONT_COMPONENT",
            "frontComponentUniversalIdentifier": "0233043a-8284-495c-98cf-9b2380ee063a"
          }
        }
      ]
    }
  ],
  "commandMenuItems": [
    {
      "universalIdentifier": "0ac3a91c-76a2-45d9-903d-2d6f18c59c1f",
      "frontComponentUniversalIdentifier": "cf44e138-08ba-42bf-8144-a130d3f7448d",
      "label": "Connect Granola account",
      "availabilityType": "GLOBAL"
    }
  ]
}
