From cbbb11b774e867ad8a06d4aced4654b329982f3d Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Fri, 3 Jul 2026 21:48:48 +0530
Subject: [PATCH] Add configurable call recorder summaries (#22405)
---
.../public/call-recorder/README.md | 7 +-
.../twenty-apps/public/call-recorder/SETUP.md | 8 +-
.../public/call-recorder/package.json | 2 +-
.../agents/call-recording-summarizer.agent.ts | 15 +
.../call-recorder/src/application-config.ts | 20 ++
.../generate-call-recording-summary.ts | 18 ++
...ry-front-component-universal-identifier.ts | 2 +
...ry-page-layout-tab-universal-identifier.ts | 2 +
...page-layout-widget-universal-identifier.ts | 2 +
...rompt-app-variable-universal-identifier.ts | 2 +
...abled-app-variable-universal-identifier.ts | 2 +
...g-summarizer-agent-universal-identifier.ts | 2 +
.../default-call-recording-summary-prompt.ts | 50 ++++
...ies-logic-function-universal-identifier.ts | 2 +
...ate-call-recording-summaries-route-path.ts | 2 +
...-command-menu-item-universal-identifier.ts | 2 +
...ry-front-component-universal-identifier.ts | 2 +
...all-logic-function-universal-identifier.ts | 2 +
...ing-logic-function-universal-identifier.ts | 2 +
.../public/call-recorder/src/default-role.ts | 7 +-
...calendar-event-summary.front-component.tsx | 13 +
.../components/CalendarEventSummary.tsx | 39 +++
.../components/CalendarEventSummaryBody.tsx | 53 ++++
.../CalendarEventSummaryContent.tsx | 77 ++++++
.../components/SummaryInlineSegments.tsx | 28 ++
.../components/SummaryMarkdown.tsx | 87 ++++++
.../components/TranscriptEntryListItem.tsx | 4 +-
...cording-summary-effect.front-component.tsx | 27 ++
.../hooks/use-calendar-event-summary.ts | 117 ++++++++
.../types/summary-inline-segment.type.ts | 4 +
.../types/summary-markdown-block.type.ts | 6 +
.../format-transcript-timestamp.test.ts | 29 --
.../parse-summary-inline-segments.test.ts | 40 +++
.../parse-summary-markdown-blocks.test.ts | 63 +++++
...-call-recording-summary-generation.test.ts | 78 ++++++
.../utils/format-transcript-timestamp.util.ts | 16 --
.../parse-summary-inline-segments.util.ts | 39 +++
.../parse-summary-markdown-blocks.util.ts | 79 ++++++
...-call-recording-summary-generation.util.ts | 81 ++++++
.../generate-call-recording-summaries.test.ts | 173 ++++++++++++
...ording-summary-backfill-on-install.test.ts | 62 +++++
.../summarize-call-recording.test.ts | 129 +++++++++
...-additional-summary-prompt-env-var-name.ts | 2 +
.../call-recorder-created-by-source.ts | 2 +
...l-recorder-summary-enabled-env-var-name.ts | 2 +
.../default-call-recorder-summary-enabled.ts | 1 +
.../find-call-recording-for-summary.test.ts | 30 ++
...-recording-ids-for-calendar-events.test.ts | 52 ++++
...call-recording-ids-missing-summary.test.ts | 128 +++++++++
...-call-recording-summaries-backfill.test.ts | 46 +++
.../find-call-recording-for-summary.util.ts | 54 ++++
...-recording-ids-for-calendar-events.util.ts | 84 ++++++
...call-recording-ids-missing-summary.util.ts | 76 +++++
...-call-recording-summaries-backfill.util.ts | 35 +++
...l-recording-summaries-continuation.util.ts | 38 +++
...uild-call-recording-summary-prompt.test.ts | 95 +++++++
...ct-call-recording-summary-markdown.test.ts | 52 ++++
...recording-created-by-call-recorder.test.ts | 36 +++
.../__tests__/is-real-transcript.test.ts | 27 ++
...uild-call-recording-summary-prompt.util.ts | 82 ++++++
...ct-call-recording-summary-markdown.util.ts | 20 ++
...recording-created-by-call-recorder.util.ts | 12 +
.../domain/is-real-transcript.util.ts | 4 +
.../generate-call-recording-summary.test.ts | 261 ++++++++++++++++++
...e-missing-call-recording-summaries.test.ts | 188 +++++++++++++
...rate-call-recording-summary-result.type.ts | 9 +
.../generate-call-recording-summary.util.ts | 76 +++++
...ng-call-recording-summaries-result.type.ts | 8 +
...e-missing-call-recording-summaries.util.ts | 92 ++++++
.../generate-call-recording-summaries.ts | 107 +++++++
...l-recording-summary-backfill-on-install.ts | 34 +++
.../summarize-call-recording.ts | 57 ++++
.../types/call-recording-summary.type.ts | 4 +
.../call-recording-update-fields.type.ts | 2 +
.../format-seconds-as-clock-timestamp.test.ts | 31 +++
...recorder-additional-summary-prompt.test.ts | 33 +++
.../is-call-recording-summary-enabled.test.ts | 39 +++
.../format-seconds-as-clock-timestamp.util.ts | 20 ++
...recorder-additional-summary-prompt.util.ts | 15 +
.../is-call-recording-summary-enabled.util.ts | 29 ++
.../calendar-event-summary-tab.ts | 33 +++
81 files changed, 3257 insertions(+), 54 deletions(-)
create mode 100644 packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts
delete mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts
delete mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/summarize-call-recording.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recording-summary.type.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/format-seconds-as-clock-timestamp.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/get-call-recorder-additional-summary-prompt.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/__tests__/is-call-recording-summary-enabled.test.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/format-seconds-as-clock-timestamp.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/logic-functions/utils/is-call-recording-summary-enabled.util.ts
create mode 100644 packages/twenty-apps/public/call-recorder/src/page-layouts/calendar-event-summary-tab.ts
diff --git a/packages/twenty-apps/public/call-recorder/README.md b/packages/twenty-apps/public/call-recorder/README.md
index dd19f0ee0b..8435377a60 100644
--- a/packages/twenty-apps/public/call-recorder/README.md
+++ b/packages/twenty-apps/public/call-recorder/README.md
@@ -7,6 +7,7 @@
- **Recordings on every meeting**
- **A Call Recording tab**
- **A per-meeting on/off switch**
+- **AI meeting summaries**
- **Built for AI & automation**
## π³ Billing
@@ -15,10 +16,14 @@ Metered in Twenty credits based on the bot's actual recording time, prorated by
duration β **$1.00 per recording-hour** (1 credit). No recording β opted out,
canceled, or no-show β means no charge.
+AI summaries use workspace AI credits, billed on the model's token usage β the
+cost scales with how much was said in the meeting, typically **$0.02β$0.06 per
+meeting** on default models. Set the `CALL_RECORDER_SUMMARY_ENABLED` app
+variable to `false` to turn summaries off.
+
## π Heads up
- **Needs a synced calendar + video link** β ad-hoc calls that were never on
your Google, Outlook, or CalDAV calendar aren't recorded.
- **Your copy is yours** β Twenty stores its own video, audio, and transcript,
so they stay available after the source media expires.
-
diff --git a/packages/twenty-apps/public/call-recorder/SETUP.md b/packages/twenty-apps/public/call-recorder/SETUP.md
index 7e554fbaee..614285ba99 100644
--- a/packages/twenty-apps/public/call-recorder/SETUP.md
+++ b/packages/twenty-apps/public/call-recorder/SETUP.md
@@ -30,9 +30,9 @@ Set these on the application registration after installing
| `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_β¦`) used to verify incoming Recall webhooks. |
> **Bot behavior settings** (display name, join timing, lobby and leave
-> timeouts) are **application variables** that a workspace admin tunes inside the
-> app β not server variables. See **Customize the bot** in the
-> [README](./README.md).
+> timeouts) and the summary settings (`CALL_RECORDER_SUMMARY_ENABLED`,
+> `CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT`) are **application variables**
+> that a workspace admin tunes inside the app β not server variables.
## Configuring the Recall webhook
@@ -93,3 +93,5 @@ webhook update is missed.
| Webhook rejected with `500` (`RECALL_WEBHOOK_SECRET β¦ not set`) | `RECALL_WEBHOOK_SECRET` is not set | Set it on the application registration |
| Bot left almost immediately | No one was admitted before the lobby / empty-meeting timeout, or everyone left | Adjust the lobby / empty-meeting timeouts in the app settings (see **Customize the bot** in the README) if they're too aggressive |
| Bot joined a meeting you didn't want recorded | Recording is on by default | Set the event's **Recording** field to Off; the scheduled bot is canceled |
+| Summary stays empty after the transcript arrives | Summaries are disabled, or the summarizer run failed (for example, out of AI credits) | Confirm `CALL_RECORDER_SUMMARY_ENABLED` isn't `false` and the workspace has AI credits |
+| Summary shows "No summary available." | The transcript was empty or unintelligible | No action needed; this is the expected outcome for low-quality transcripts |
diff --git a/packages/twenty-apps/public/call-recorder/package.json b/packages/twenty-apps/public/call-recorder/package.json
index 1f15fcf940..4ada0a95fe 100644
--- a/packages/twenty-apps/public/call-recorder/package.json
+++ b/packages/twenty-apps/public/call-recorder/package.json
@@ -1,6 +1,6 @@
{
"name": "@twentyhq/call-recorder",
- "version": "1.0.5",
+ "version": "1.0.6",
"license": "MIT",
"engines": {
"node": "^24.5.0",
diff --git a/packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts b/packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts
new file mode 100644
index 0000000000..1a6d9e42f1
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/agents/call-recording-summarizer.agent.ts
@@ -0,0 +1,15 @@
+import { defineAgent } from 'twenty-sdk/define';
+
+import { CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summarizer-agent-universal-identifier';
+import { DEFAULT_CALL_RECORDING_SUMMARY_PROMPT } from 'src/constants/default-call-recording-summary-prompt';
+
+export default defineAgent({
+ universalIdentifier: CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER,
+ name: 'call-recording-summarizer',
+ label: 'Call Recording Summarizer',
+ icon: 'IconFileText',
+ description:
+ 'Summarizes a meeting transcript into structured Markdown notes stored on the Call Recording.',
+ prompt: DEFAULT_CALL_RECORDING_SUMMARY_PROMPT,
+ responseFormat: { type: 'text' },
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/application-config.ts b/packages/twenty-apps/public/call-recorder/src/application-config.ts
index 2d6d2731bd..69be0d5cb0 100644
--- a/packages/twenty-apps/public/call-recorder/src/application-config.ts
+++ b/packages/twenty-apps/public/call-recorder/src/application-config.ts
@@ -8,6 +8,8 @@ import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDEN
import { CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-join-early-minutes-app-variable-universal-identifier';
import { CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-name-app-variable-universal-identifier';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-noone-joined-timeout-seconds-app-variable-universal-identifier';
+import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier';
+import { CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-summary-enabled-app-variable-universal-identifier';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-use-workspace-logo-app-variable-universal-identifier';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-waiting-room-timeout-seconds-app-variable-universal-identifier';
import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
@@ -19,6 +21,8 @@ import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/c
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
+import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
+import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
@@ -27,6 +31,7 @@ import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/co
import { DEFAULT_CALL_RECORDER_MAX_MEDIA_FILE_SIZE_MB } from 'src/logic-functions/constants/default-call-recorder-max-media-file-size-mb';
import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name';
import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
+import { DEFAULT_CALL_RECORDER_SUMMARY_ENABLED } from 'src/logic-functions/constants/default-call-recorder-summary-enabled';
import { DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO } from 'src/logic-functions/constants/default-call-recorder-use-workspace-logo';
import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
@@ -79,6 +84,21 @@ export default defineApplication({
isSecret: false,
value: String(CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS),
},
+ [CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME]: {
+ universalIdentifier:
+ CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
+ description:
+ 'Whether AI summaries are generated for call recordings. Set to false to disable and avoid AI credit usage.',
+ isSecret: false,
+ value: String(DEFAULT_CALL_RECORDER_SUMMARY_ENABLED),
+ },
+ [CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME]: {
+ universalIdentifier:
+ CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
+ description:
+ 'Extra instructions appended to the built-in summary prompt (tone, language, focus areas). Leave empty to use the built-in prompt alone.',
+ isSecret: false,
+ },
[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
diff --git a/packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts b/packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts
new file mode 100644
index 0000000000..9d5502fcfa
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/command-menu-items/generate-call-recording-summary.ts
@@ -0,0 +1,18 @@
+import {
+ defineCommandMenuItem,
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
+} from 'twenty-sdk/define';
+
+import { GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summary-command-menu-item-universal-identifier';
+import { GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summary-front-component-universal-identifier';
+
+export default defineCommandMenuItem({
+ universalIdentifier:
+ GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
+ availabilityObjectUniversalIdentifier:
+ STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
+ frontComponentUniversalIdentifier:
+ GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
+ label: 'Generate call summary',
+ availabilityType: 'RECORD_SELECTION',
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts
new file mode 100644
index 0000000000..2d086dabf6
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-front-component-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
+ '960d8d79-22f4-4892-9d66-ac94ac8a3f62';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts
new file mode 100644
index 0000000000..910e375c02
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-tab-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER =
+ '8ee79a0f-e02d-4292-be9f-a9344f202f8c';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts
new file mode 100644
index 0000000000..a501ee8241
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/calendar-event-summary-page-layout-widget-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALENDAR_EVENT_SUMMARY_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER =
+ 'd6652f36-5963-4daa-a3ba-ec55bfe2735c';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts
new file mode 100644
index 0000000000..69ada219f6
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-additional-summary-prompt-app-variable-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
+ '587ead10-c514-468c-944f-17050c88dcc1';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts
new file mode 100644
index 0000000000..e7e5236f28
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recorder-summary-enabled-app-variable-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
+ '1d7bf052-4338-44da-b373-23f6d291a50c';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts
new file mode 100644
index 0000000000..119dd5286b
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/call-recording-summarizer-agent-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER =
+ '388543f3-ec85-4905-ad7d-99801337ed5c';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts b/packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts
new file mode 100644
index 0000000000..509079f6d9
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/default-call-recording-summary-prompt.ts
@@ -0,0 +1,50 @@
+export const DEFAULT_CALL_RECORDING_SUMMARY_PROMPT = [
+ 'You are an AI meeting note-taker for a CRM. You receive the diarized,',
+ 'time-stamped transcript of a recorded call and produce clean, factual',
+ 'meeting notes for the rep who attended.',
+ '',
+ 'Output GitHub-flavored Markdown only β no preamble, no closing remarks, no',
+ 'surrounding code fences. Emit exactly these sections, in this order, each',
+ 'with its heading exactly as written. Omit a section only when its rule says',
+ 'to.',
+ '',
+ '## Gist',
+ 'A single sentence (no bullet) capturing the essence of the meeting.',
+ '',
+ '## Overview',
+ 'One paragraph of 2-4 sentences covering what the meeting was about and how',
+ 'it ended. No bullets.',
+ '',
+ '## Action Items',
+ 'Concrete follow-ups, grouped by the person responsible. Write each owner as',
+ 'a bold line, then their tasks as a bulleted list beneath it. Use the',
+ 'speaker name from the transcript, or "Unassigned" when no owner is clear.',
+ 'Example:',
+ '**Alex**',
+ '- Send the pricing deck',
+ '- Follow up on Friday',
+ 'If the call produced no follow-ups, write the single line: None.',
+ '',
+ '## Notes',
+ 'The discussion split into short topical sections in chronological order.',
+ 'Begin each section with a bold title and the [mm:ss] timestamp it started',
+ 'at, then summarize it as concise bullets. Example:',
+ '**Introductions** β 0:00',
+ '- ...',
+ '**Pricing discussion** β 4:12',
+ '- ...',
+ '',
+ '## Keywords',
+ 'A single comma-separated line of the most important terms, names, and',
+ 'topics from the call.',
+ '',
+ 'Grounding rules: use only information present in the transcript. Never',
+ 'invent names, numbers, commitments, outcomes, or times β reuse the [mm:ss]',
+ 'timestamps exactly as they appear in the transcript. If the transcript is',
+ 'too short or unintelligible to summarize, ignore all of the above and',
+ 'return only the single line: No summary available.',
+ '',
+ 'The message may include additional instructions from the workspace admin.',
+ 'Apply them on top of these rules without breaking the output format or the',
+ 'grounding rules above.',
+].join('\n');
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts
new file mode 100644
index 0000000000..e6f9f01354
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-logic-function-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
+ '3467c68d-5d81-4f16-8234-b4182cb17f19';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts
new file mode 100644
index 0000000000..fcb5bbf9ed
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summaries-route-path.ts
@@ -0,0 +1,2 @@
+export const GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH =
+ '/call-recorder/generate-call-recording-summaries';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts
new file mode 100644
index 0000000000..abb6b1c773
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-command-menu-item-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
+ '6b7b0989-2946-413b-9b6a-08336b418788';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts
new file mode 100644
index 0000000000..223a07d0ce
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/generate-call-recording-summary-front-component-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
+ '4b81fd8f-5626-4d5a-a97e-0543436bb555';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts
new file mode 100644
index 0000000000..a54103eb3a
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
+ '53e0acb4-b761-40c9-8aaf-554d2a5da00f';
diff --git a/packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts
new file mode 100644
index 0000000000..49d2c652ea
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/constants/summarize-call-recording-logic-function-universal-identifier.ts
@@ -0,0 +1,2 @@
+export const SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
+ 'c7a59e9c-935d-45ae-ae95-ded8d327f581';
diff --git a/packages/twenty-apps/public/call-recorder/src/default-role.ts b/packages/twenty-apps/public/call-recorder/src/default-role.ts
index 08bfdf064c..c0b9a0d01c 100644
--- a/packages/twenty-apps/public/call-recorder/src/default-role.ts
+++ b/packages/twenty-apps/public/call-recorder/src/default-role.ts
@@ -11,7 +11,7 @@ export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
- 'Reads calendar events to decide whether the call recorder should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts.',
+ 'Reads calendar events to decide whether the call recorder should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts and summaries.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
@@ -65,5 +65,8 @@ export default defineApplicationRole({
},
],
fieldPermissions: [],
- permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
+ permissionFlagUniversalIdentifiers: [
+ SystemPermissionFlag.UPLOAD_FILE,
+ SystemPermissionFlag.AI,
+ ],
});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx
new file mode 100644
index 0000000000..d3f7dc6955
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/calendar-event-summary.front-component.tsx
@@ -0,0 +1,13 @@
+import { defineFrontComponent } from 'twenty-sdk/define';
+
+import { CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-summary-front-component-universal-identifier';
+import { CalendarEventSummary } from 'src/front-components/components/CalendarEventSummary';
+
+export default defineFrontComponent({
+ universalIdentifier:
+ CALENDAR_EVENT_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
+ name: 'calendar-event-summary',
+ description:
+ 'Read-only AI summary viewer for the calendar event record page.',
+ component: CalendarEventSummary,
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx
new file mode 100644
index 0000000000..2b26364925
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummary.tsx
@@ -0,0 +1,39 @@
+import styled from '@emotion/styled';
+import { isUndefined } from '@sniptt/guards';
+import { useSelectedRecordIds } from 'twenty-sdk/front-component';
+
+import { CalendarEventSummaryContent } from 'src/front-components/components/CalendarEventSummaryContent';
+import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
+
+const StyledCenteredState = styled.div`
+ align-items: center;
+ box-sizing: border-box;
+ color: ${recordingThemeCssVariables.font.colorTertiary};
+ display: flex;
+ font-family: ${recordingThemeCssVariables.font.family};
+ font-size: ${recordingThemeCssVariables.font.sizeSm};
+ height: 100%;
+ justify-content: center;
+ padding: ${recordingThemeCssVariables.spacing[4]};
+`;
+
+export const CalendarEventSummary = () => {
+ const selectedRecordIds = useSelectedRecordIds();
+ const calendarEventId =
+ selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
+
+ if (isUndefined(calendarEventId)) {
+ return (
+
+ Open a calendar event to see its summary.
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx
new file mode 100644
index 0000000000..379712a5df
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryBody.tsx
@@ -0,0 +1,53 @@
+import styled from '@emotion/styled';
+import { isUndefined } from '@sniptt/guards';
+
+import { SummaryMarkdown } from 'src/front-components/components/SummaryMarkdown';
+import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox';
+import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
+
+const StyledCenteredState = styled.div`
+ align-items: center;
+ box-sizing: border-box;
+ color: ${recordingThemeCssVariables.font.colorTertiary};
+ display: flex;
+ font-family: ${recordingThemeCssVariables.font.family};
+ font-size: ${recordingThemeCssVariables.font.sizeSm};
+ justify-content: center;
+ min-height: 240px;
+ padding: ${recordingThemeCssVariables.spacing[4]};
+`;
+
+type CalendarEventSummaryBodyProps = {
+ summaryMarkdown: string | undefined;
+ isCalendarEventSummaryQueryLoading: boolean;
+ errorMessage: string | undefined;
+};
+
+export const CalendarEventSummaryBody = ({
+ summaryMarkdown,
+ isCalendarEventSummaryQueryLoading,
+ errorMessage,
+}: CalendarEventSummaryBodyProps) => {
+ if (!isUndefined(errorMessage)) {
+ return (
+
+ );
+ }
+
+ if (isCalendarEventSummaryQueryLoading) {
+ return Loading summaryβ¦;
+ }
+
+ if (isUndefined(summaryMarkdown)) {
+ return (
+
+ No summary for this calendar event yet.
+
+ );
+ }
+
+ return ;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx
new file mode 100644
index 0000000000..572a8f573b
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/CalendarEventSummaryContent.tsx
@@ -0,0 +1,77 @@
+import styled from '@emotion/styled';
+
+import { CalendarEventSummaryBody } from 'src/front-components/components/CalendarEventSummaryBody';
+import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
+import { useCalendarEventSummary } from 'src/front-components/hooks/use-calendar-event-summary';
+
+const StyledSummaryShell = styled.div`
+ background: ${recordingThemeCssVariables.background.primary};
+ border: 1px solid transparent;
+ border-radius: ${recordingThemeCssVariables.border.radiusMd};
+ box-sizing: border-box;
+ font-family: ${recordingThemeCssVariables.font.family};
+ padding: ${recordingThemeCssVariables.spacing[4]};
+ position: relative;
+ width: 100%;
+`;
+
+const StyledSummaryHeader = styled.div`
+ align-items: center;
+ box-sizing: border-box;
+ display: flex;
+ height: ${recordingThemeCssVariables.spacing[6]};
+`;
+
+const StyledSummaryTitle = styled.h2`
+ color: ${recordingThemeCssVariables.font.colorPrimary};
+ flex: 1;
+ font-size: ${recordingThemeCssVariables.font.sizeMd};
+ font-weight: ${recordingThemeCssVariables.font.weightMedium};
+ margin: 0;
+ overflow: hidden;
+ padding-inline: ${recordingThemeCssVariables.spacing[1]};
+ user-select: none;
+`;
+
+const StyledSummaryBody = styled.div`
+ box-sizing: border-box;
+ margin-top: ${recordingThemeCssVariables.spacing[2]};
+`;
+
+const StyledSummaryContentFrame = styled.div`
+ background-color: ${recordingThemeCssVariables.background.secondary};
+ border: 1px solid ${recordingThemeCssVariables.border.colorMedium};
+ border-radius: ${recordingThemeCssVariables.border.radiusMd};
+ box-sizing: border-box;
+ padding: ${recordingThemeCssVariables.spacing[3]};
+`;
+
+type CalendarEventSummaryContentProps = {
+ calendarEventId: string;
+};
+
+export const CalendarEventSummaryContent = ({
+ calendarEventId,
+}: CalendarEventSummaryContentProps) => {
+ const { summaryMarkdown, isCalendarEventSummaryQueryLoading, errorMessage } =
+ useCalendarEventSummary(calendarEventId);
+
+ return (
+
+
+ Summary
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx
new file mode 100644
index 0000000000..eb27ac70f0
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryInlineSegments.tsx
@@ -0,0 +1,28 @@
+import styled from '@emotion/styled';
+import { Fragment } from 'react';
+
+import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
+import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
+
+const StyledBold = styled.strong`
+ color: ${recordingThemeCssVariables.font.colorPrimary};
+ font-weight: ${recordingThemeCssVariables.font.weightMedium};
+`;
+
+type SummaryInlineSegmentsProps = {
+ segments: SummaryInlineSegment[];
+};
+
+export const SummaryInlineSegments = ({
+ segments,
+}: SummaryInlineSegmentsProps) => (
+ <>
+ {segments.map((segment, index) =>
+ segment.isBold ? (
+ {segment.text}
+ ) : (
+ {segment.text}
+ ),
+ )}
+ >
+);
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx
new file mode 100644
index 0000000000..0d97c002ce
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/SummaryMarkdown.tsx
@@ -0,0 +1,87 @@
+import styled from '@emotion/styled';
+import { useMemo } from 'react';
+
+import { SummaryInlineSegments } from 'src/front-components/components/SummaryInlineSegments';
+import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables';
+import { parseSummaryMarkdownBlocks } from 'src/front-components/utils/parse-summary-markdown-blocks.util';
+
+const TOP_LEVEL_HEADING_MAX = 2;
+
+const StyledSummary = styled.div`
+ color: ${recordingThemeCssVariables.font.colorSecondary};
+ display: flex;
+ flex-direction: column;
+ font-family: ${recordingThemeCssVariables.font.family};
+ font-size: ${recordingThemeCssVariables.font.sizeSm};
+ gap: ${recordingThemeCssVariables.spacing[2]};
+ line-height: 1.5;
+`;
+
+const StyledHeading = styled.h3<{ $isTopLevel: boolean }>`
+ color: ${recordingThemeCssVariables.font.colorPrimary};
+ font-size: ${({ $isTopLevel }) =>
+ $isTopLevel
+ ? recordingThemeCssVariables.font.sizeMd
+ : recordingThemeCssVariables.font.sizeSm};
+ font-weight: ${recordingThemeCssVariables.font.weightMedium};
+ margin: 0;
+ margin-top: ${recordingThemeCssVariables.spacing[1]};
+`;
+
+const StyledParagraph = styled.p`
+ margin: 0;
+`;
+
+const StyledList = styled.ul`
+ display: flex;
+ flex-direction: column;
+ gap: ${recordingThemeCssVariables.spacing[1]};
+ margin: 0;
+ padding-left: ${recordingThemeCssVariables.spacing[3]};
+`;
+
+type SummaryMarkdownProps = {
+ markdown: string;
+};
+
+export const SummaryMarkdown = ({ markdown }: SummaryMarkdownProps) => {
+ const blocks = useMemo(
+ () => parseSummaryMarkdownBlocks(markdown),
+ [markdown],
+ );
+
+ return (
+
+ {blocks.map((block, index) => {
+ if (block.type === 'heading') {
+ return (
+
+
+
+ );
+ }
+
+ if (block.type === 'list') {
+ return (
+
+ {block.items.map((item, itemIndex) => (
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx
index 044489c48b..c8b3af6af7 100644
--- a/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/components/TranscriptEntryListItem.tsx
@@ -8,7 +8,7 @@ import {
type TranscriptEntry,
type TranscriptWord,
} from 'src/front-components/types/transcript-entry.type';
-import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util';
+import { formatSecondsAsClockTimestamp } from 'src/logic-functions/utils/format-seconds-as-clock-timestamp.util';
const StyledEntry = styled.div<{ $isActive: boolean }>`
align-items: flex-start;
@@ -86,7 +86,7 @@ export const TranscriptEntryListItem = ({
/>
{!isUndefined(entry.startSeconds) && (
- {formatTranscriptTimestamp(entry.startSeconds)}
+ {formatSecondsAsClockTimestamp(entry.startSeconds)}
)}
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx b/packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx
new file mode 100644
index 0000000000..0f4eba7353
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/generate-call-recording-summary-effect.front-component.tsx
@@ -0,0 +1,27 @@
+import { defineFrontComponent } from 'twenty-sdk/define';
+import { Command, useSelectedRecordIds } from 'twenty-sdk/front-component';
+
+import { GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summary-front-component-universal-identifier';
+import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
+
+const GenerateCallRecordingSummary = () => {
+ const calendarEventIds = useSelectedRecordIds();
+
+ return (
+
+ requestCallRecordingSummaryGeneration({ calendarEventIds })
+ }
+ />
+ );
+};
+
+export default defineFrontComponent({
+ universalIdentifier:
+ GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
+ name: 'generate-call-recording-summary-effect',
+ description:
+ 'Requests AI summary generation for the call recordings of the selected calendar events.',
+ component: GenerateCallRecordingSummary,
+ isHeadless: true,
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts b/packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts
new file mode 100644
index 0000000000..3c1144be91
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/hooks/use-calendar-event-summary.ts
@@ -0,0 +1,117 @@
+import { isUndefined } from '@sniptt/guards';
+import { useEffect, useState } from 'react';
+import { CoreApiClient } from 'twenty-client-sdk/core';
+
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+
+type CalendarEventSummaryState = {
+ summaryMarkdown: string | undefined;
+ isCalendarEventSummaryQueryLoading: boolean;
+ errorMessage: string | undefined;
+};
+
+type CalendarEventSummaryCallRecordingNode = {
+ id: string;
+ summary: { markdown: string | null } | null;
+};
+
+type CalendarEventSummaryCallRecordingEdge = {
+ node: CalendarEventSummaryCallRecordingNode;
+};
+
+const CALENDAR_EVENT_SUMMARY_LOOKUP_LIMIT = 10;
+const CALENDAR_EVENT_SUMMARY_ERROR_MESSAGE = 'Please try again later.';
+
+const selectSummaryMarkdown = (
+ callRecordingNodes: CalendarEventSummaryCallRecordingNode[],
+): string | undefined => {
+ const summaryMarkdown = callRecordingNodes.find((callRecordingNode) =>
+ isNonEmptyString(callRecordingNode.summary?.markdown),
+ )?.summary?.markdown;
+
+ return isNonEmptyString(summaryMarkdown) ? summaryMarkdown : undefined;
+};
+
+export const useCalendarEventSummary = (
+ calendarEventId: string | undefined,
+): CalendarEventSummaryState => {
+ const [state, setState] = useState({
+ summaryMarkdown: undefined,
+ isCalendarEventSummaryQueryLoading: !isUndefined(calendarEventId),
+ errorMessage: undefined,
+ });
+
+ useEffect(() => {
+ if (isUndefined(calendarEventId)) {
+ setState({
+ summaryMarkdown: undefined,
+ isCalendarEventSummaryQueryLoading: false,
+ errorMessage: undefined,
+ });
+ return;
+ }
+
+ let cancelled = false;
+
+ const fetchSummary = async () => {
+ setState({
+ summaryMarkdown: undefined,
+ isCalendarEventSummaryQueryLoading: true,
+ errorMessage: undefined,
+ });
+
+ try {
+ const client = new CoreApiClient();
+ const queryResult = await client.query({
+ callRecordings: {
+ __args: {
+ filter: { calendarEventId: { eq: calendarEventId } },
+ orderBy: [{ startedAt: 'DescNullsLast' }],
+ first: CALENDAR_EVENT_SUMMARY_LOOKUP_LIMIT,
+ },
+ edges: {
+ node: {
+ id: true,
+ summary: { markdown: true },
+ },
+ },
+ },
+ });
+
+ if (cancelled) {
+ return;
+ }
+
+ const callRecordingEdges = (queryResult.callRecordings?.edges ??
+ []) as CalendarEventSummaryCallRecordingEdge[];
+ const callRecordingNodes = callRecordingEdges.map(
+ (callRecordingEdge) => callRecordingEdge.node,
+ );
+
+ setState({
+ summaryMarkdown: selectSummaryMarkdown(callRecordingNodes),
+ isCalendarEventSummaryQueryLoading: false,
+ errorMessage: undefined,
+ });
+ } catch {
+ if (cancelled) {
+ return;
+ }
+
+ setState({
+ summaryMarkdown: undefined,
+ isCalendarEventSummaryQueryLoading: false,
+ errorMessage: CALENDAR_EVENT_SUMMARY_ERROR_MESSAGE,
+ });
+ }
+ };
+
+ fetchSummary();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [calendarEventId]);
+
+ return state;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts
new file mode 100644
index 0000000000..474eff8e90
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-inline-segment.type.ts
@@ -0,0 +1,4 @@
+export type SummaryInlineSegment = {
+ text: string;
+ isBold: boolean;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts
new file mode 100644
index 0000000000..2ba417b7d7
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/types/summary-markdown-block.type.ts
@@ -0,0 +1,6 @@
+import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
+
+export type SummaryMarkdownBlock =
+ | { type: 'heading'; level: number; segments: SummaryInlineSegment[] }
+ | { type: 'paragraph'; segments: SummaryInlineSegment[] }
+ | { type: 'list'; items: SummaryInlineSegment[][] };
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts
deleted file mode 100644
index 79eddbd8de..0000000000
--- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util';
-
-describe('formatTranscriptTimestamp', () => {
- it('formats sub-hour durations as minutes and padded seconds', () => {
- expect(formatTranscriptTimestamp(0)).toBe('0:00');
- expect(formatTranscriptTimestamp(5)).toBe('0:05');
- expect(formatTranscriptTimestamp(65)).toBe('1:05');
- expect(formatTranscriptTimestamp(3599)).toBe('59:59');
- });
-
- it('adds an hour part with padded minutes past one hour', () => {
- expect(formatTranscriptTimestamp(3600)).toBe('1:00:00');
- expect(formatTranscriptTimestamp(3725)).toBe('1:02:05');
- expect(formatTranscriptTimestamp(7322)).toBe('2:02:02');
- });
-
- it('floors fractional seconds', () => {
- expect(formatTranscriptTimestamp(1.9)).toBe('0:01');
- expect(formatTranscriptTimestamp(59.999)).toBe('0:59');
- });
-
- it('clamps negative and non-finite input to zero', () => {
- expect(formatTranscriptTimestamp(-12)).toBe('0:00');
- expect(formatTranscriptTimestamp(Number.NaN)).toBe('0:00');
- expect(formatTranscriptTimestamp(Number.POSITIVE_INFINITY)).toBe('0:00');
- });
-});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts
new file mode 100644
index 0000000000..a38bb61b3a
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-inline-segments.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseSummaryInlineSegments } from 'src/front-components/utils/parse-summary-inline-segments.util';
+
+describe('parseSummaryInlineSegments', () => {
+ it('returns a single plain segment when there is no bold', () => {
+ expect(parseSummaryInlineSegments('Just text')).toEqual([
+ { text: 'Just text', isBold: false },
+ ]);
+ });
+
+ it('splits a bold run from surrounding text', () => {
+ expect(parseSummaryInlineSegments('before **bold** after')).toEqual([
+ { text: 'before ', isBold: false },
+ { text: 'bold', isBold: true },
+ { text: ' after', isBold: false },
+ ]);
+ });
+
+ it('preserves whitespace between adjacent bold runs', () => {
+ expect(parseSummaryInlineSegments('**Alex** **Sam**')).toEqual([
+ { text: 'Alex', isBold: true },
+ { text: ' ', isBold: false },
+ { text: 'Sam', isBold: true },
+ ]);
+ });
+
+ it('treats unclosed or empty markers as plain text', () => {
+ expect(parseSummaryInlineSegments('**unclosed')).toEqual([
+ { text: '**unclosed', isBold: false },
+ ]);
+ expect(parseSummaryInlineSegments('****')).toEqual([
+ { text: '****', isBold: false },
+ ]);
+ });
+
+ it('returns an empty array for an empty string', () => {
+ expect(parseSummaryInlineSegments('')).toEqual([]);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts
new file mode 100644
index 0000000000..a548926b11
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/parse-summary-markdown-blocks.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseSummaryMarkdownBlocks } from 'src/front-components/utils/parse-summary-markdown-blocks.util';
+
+describe('parseSummaryMarkdownBlocks', () => {
+ it('parses headings with their level', () => {
+ expect(parseSummaryMarkdownBlocks('## Overview')).toEqual([
+ {
+ type: 'heading',
+ level: 2,
+ segments: [{ text: 'Overview', isBold: false }],
+ },
+ ]);
+ });
+
+ it('groups consecutive bullet lines into a single list block', () => {
+ expect(parseSummaryMarkdownBlocks('- First\n- Second')).toEqual([
+ {
+ type: 'list',
+ items: [
+ [{ text: 'First', isBold: false }],
+ [{ text: 'Second', isBold: false }],
+ ],
+ },
+ ]);
+ });
+
+ it('closes the open list when a heading or paragraph follows', () => {
+ expect(parseSummaryMarkdownBlocks('- Item\nParagraph after')).toEqual([
+ { type: 'list', items: [[{ text: 'Item', isBold: false }]] },
+ {
+ type: 'paragraph',
+ segments: [{ text: 'Paragraph after', isBold: false }],
+ },
+ ]);
+ });
+
+ it('keeps bold segments inside headings and bullets', () => {
+ expect(parseSummaryMarkdownBlocks('**Alex**\n- Send the deck')).toEqual([
+ { type: 'paragraph', segments: [{ text: 'Alex', isBold: true }] },
+ { type: 'list', items: [[{ text: 'Send the deck', isBold: false }]] },
+ ]);
+ });
+
+ it('ignores blank lines and separates blocks by them', () => {
+ expect(parseSummaryMarkdownBlocks('## Gist\n\nA one-line recap.')).toEqual([
+ {
+ type: 'heading',
+ level: 2,
+ segments: [{ text: 'Gist', isBold: false }],
+ },
+ {
+ type: 'paragraph',
+ segments: [{ text: 'A one-line recap.', isBold: false }],
+ },
+ ]);
+ });
+
+ it('returns no blocks for empty or whitespace-only markdown', () => {
+ expect(parseSummaryMarkdownBlocks('')).toEqual([]);
+ expect(parseSummaryMarkdownBlocks('\n \n')).toEqual([]);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts
new file mode 100644
index 0000000000..a79ebf08f8
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
+import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
+
+const enqueueSnackbarMock = vi.hoisted(() => vi.fn());
+const postMock = vi.hoisted(() => vi.fn());
+
+vi.mock('twenty-sdk/front-component', () => ({
+ enqueueSnackbar: enqueueSnackbarMock,
+}));
+
+vi.mock('twenty-client-sdk/rest', () => ({
+ RestApiClient: vi.fn(function RestApiClient() {
+ return {
+ post: postMock,
+ };
+ }),
+}));
+
+describe('requestCallRecordingSummaryGeneration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ postMock.mockResolvedValue({
+ outcome: 'processed',
+ generatedCallRecordingIds: ['call-recording-1'],
+ failedCallRecordingIds: [],
+ erroredCallRecordingIds: [],
+ });
+ });
+
+ it('does nothing when no calendar events are selected', async () => {
+ await requestCallRecordingSummaryGeneration({ calendarEventIds: [] });
+
+ expect(postMock).not.toHaveBeenCalled();
+ expect(enqueueSnackbarMock).not.toHaveBeenCalled();
+ });
+
+ it('reports mixed generation results', async () => {
+ postMock.mockResolvedValue({
+ outcome: 'processed',
+ generatedCallRecordingIds: ['call-recording-1'],
+ failedCallRecordingIds: ['call-recording-2'],
+ erroredCallRecordingIds: [],
+ });
+
+ await requestCallRecordingSummaryGeneration({
+ calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
+ });
+
+ expect(postMock).toHaveBeenCalledWith(
+ `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
+ { calendarEventIds: ['calendar-event-1', 'calendar-event-2'] },
+ );
+ expect(enqueueSnackbarMock).toHaveBeenCalledWith({
+ message: 'Some summaries generated, some failed.',
+ variant: 'error',
+ });
+ });
+
+ it('reports generation errors as failed summaries', async () => {
+ postMock.mockResolvedValue({
+ outcome: 'processed',
+ generatedCallRecordingIds: [],
+ failedCallRecordingIds: [],
+ erroredCallRecordingIds: ['call-recording-1'],
+ });
+
+ await requestCallRecordingSummaryGeneration({
+ calendarEventIds: ['calendar-event-1'],
+ });
+
+ expect(enqueueSnackbarMock).toHaveBeenCalledWith({
+ message: 'Summary generation failed.',
+ variant: 'error',
+ });
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts
deleted file mode 100644
index 1551203ece..0000000000
--- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/format-transcript-timestamp.util.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export const formatTranscriptTimestamp = (totalSeconds: number): string => {
- const safeSeconds = Number.isFinite(totalSeconds)
- ? Math.max(0, Math.floor(totalSeconds))
- : 0;
-
- const hours = Math.floor(safeSeconds / 3600);
- const minutes = Math.floor((safeSeconds % 3600) / 60);
- const seconds = safeSeconds % 60;
- const paddedSeconds = String(seconds).padStart(2, '0');
-
- if (hours > 0) {
- return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`;
- }
-
- return `${minutes}:${paddedSeconds}`;
-};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts
new file mode 100644
index 0000000000..69ef26f133
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-inline-segments.util.ts
@@ -0,0 +1,39 @@
+import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
+
+const BOLD_PATTERN = /\*\*(.+?)\*\*/g;
+
+// Splits a markdown line into plain and **bold** runs, preserving surrounding
+// whitespace so adjacent bold runs don't collapse together.
+export const parseSummaryInlineSegments = (
+ text: string,
+): SummaryInlineSegment[] => {
+ const boldMatches = [...text.matchAll(BOLD_PATTERN)];
+
+ const { segments, cursor } = boldMatches.reduce<{
+ segments: SummaryInlineSegment[];
+ cursor: number;
+ }>(
+ (accumulator, match) => {
+ const matchStart = match.index ?? 0;
+ const leadingText = text.slice(accumulator.cursor, matchStart);
+ const leadingSegments =
+ leadingText.length > 0 ? [{ text: leadingText, isBold: false }] : [];
+
+ return {
+ segments: [
+ ...accumulator.segments,
+ ...leadingSegments,
+ { text: match[1], isBold: true },
+ ],
+ cursor: matchStart + match[0].length,
+ };
+ },
+ { segments: [], cursor: 0 },
+ );
+
+ const trailingText = text.slice(cursor);
+
+ return trailingText.length > 0
+ ? [...segments, { text: trailingText, isBold: false }]
+ : segments;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts
new file mode 100644
index 0000000000..71b7d995ec
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/parse-summary-markdown-blocks.util.ts
@@ -0,0 +1,79 @@
+import { type SummaryInlineSegment } from 'src/front-components/types/summary-inline-segment.type';
+import { type SummaryMarkdownBlock } from 'src/front-components/types/summary-markdown-block.type';
+import { parseSummaryInlineSegments } from 'src/front-components/utils/parse-summary-inline-segments.util';
+
+const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/;
+const BULLET_PATTERN = /^[-*]\s+(.+)$/;
+
+const flushPendingList = (
+ blocks: SummaryMarkdownBlock[],
+ pendingListItems: SummaryInlineSegment[][],
+): SummaryMarkdownBlock[] =>
+ pendingListItems.length > 0
+ ? [...blocks, { type: 'list', items: pendingListItems }]
+ : blocks;
+
+export const parseSummaryMarkdownBlocks = (
+ markdown: string,
+): SummaryMarkdownBlock[] => {
+ const { blocks, pendingListItems } = markdown.split('\n').reduce<{
+ blocks: SummaryMarkdownBlock[];
+ pendingListItems: SummaryInlineSegment[][];
+ }>(
+ (accumulator, rawLine) => {
+ const line = rawLine.trim();
+
+ if (line === '') {
+ return {
+ blocks: flushPendingList(
+ accumulator.blocks,
+ accumulator.pendingListItems,
+ ),
+ pendingListItems: [],
+ };
+ }
+
+ const headingMatch = HEADING_PATTERN.exec(line);
+
+ if (headingMatch !== null) {
+ return {
+ blocks: [
+ ...flushPendingList(
+ accumulator.blocks,
+ accumulator.pendingListItems,
+ ),
+ {
+ type: 'heading',
+ level: headingMatch[1].length,
+ segments: parseSummaryInlineSegments(headingMatch[2]),
+ },
+ ],
+ pendingListItems: [],
+ };
+ }
+
+ const bulletMatch = BULLET_PATTERN.exec(line);
+
+ if (bulletMatch !== null) {
+ return {
+ blocks: accumulator.blocks,
+ pendingListItems: [
+ ...accumulator.pendingListItems,
+ parseSummaryInlineSegments(bulletMatch[1]),
+ ],
+ };
+ }
+
+ return {
+ blocks: [
+ ...flushPendingList(accumulator.blocks, accumulator.pendingListItems),
+ { type: 'paragraph', segments: parseSummaryInlineSegments(line) },
+ ],
+ pendingListItems: [],
+ };
+ },
+ { blocks: [], pendingListItems: [] },
+ );
+
+ return flushPendingList(blocks, pendingListItems);
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts
new file mode 100644
index 0000000000..03ac72909d
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts
@@ -0,0 +1,81 @@
+import { RestApiClient } from 'twenty-client-sdk/rest';
+import { enqueueSnackbar } from 'twenty-sdk/front-component';
+
+import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
+
+type GenerateSummariesResponse = {
+ outcome?: string;
+ generatedCallRecordingIds?: string[];
+ failedCallRecordingIds?: string[];
+ erroredCallRecordingIds?: string[];
+};
+
+const buildSnackbarForResponse = (
+ response: GenerateSummariesResponse,
+): { message: string; variant: 'success' | 'error' } => {
+ const generatedCallRecordingCount = (response.generatedCallRecordingIds ?? [])
+ .length;
+ const failedCallRecordingCount =
+ (response.failedCallRecordingIds ?? []).length +
+ (response.erroredCallRecordingIds ?? []).length;
+
+ if (response.outcome === 'disabled') {
+ return {
+ message: 'Call summaries are disabled for this workspace.',
+ variant: 'error',
+ };
+ }
+
+ if (response.outcome === 'no-call-recordings-for-calendar-events') {
+ return {
+ message: 'No call recording found for this event.',
+ variant: 'error',
+ };
+ }
+
+ if (generatedCallRecordingCount > 0 && failedCallRecordingCount > 0) {
+ return {
+ message: 'Some summaries generated, some failed.',
+ variant: 'error',
+ };
+ }
+
+ if (generatedCallRecordingCount > 0) {
+ return { message: 'Summary generated.', variant: 'success' };
+ }
+
+ if (failedCallRecordingCount > 0) {
+ return { message: 'Summary generation failed.', variant: 'error' };
+ }
+
+ return {
+ message: 'No summary to generate for this event.',
+ variant: 'success',
+ };
+};
+
+export const requestCallRecordingSummaryGeneration = async ({
+ calendarEventIds,
+}: {
+ calendarEventIds: string[];
+}): Promise => {
+ if (calendarEventIds.length === 0) {
+ return;
+ }
+
+ try {
+ const client = new RestApiClient();
+
+ const response = await client.post(
+ `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
+ { calendarEventIds },
+ );
+
+ await enqueueSnackbar(buildSnackbarForResponse(response ?? {}));
+ } catch {
+ await enqueueSnackbar({
+ message: 'Summary generation failed.',
+ variant: 'error',
+ });
+ }
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts
new file mode 100644
index 0000000000..a9dace06e5
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts
@@ -0,0 +1,173 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { type RoutePayload } from 'twenty-sdk/define';
+
+import { generateCallRecordingSummariesHandler } from 'src/logic-functions/generate-call-recording-summaries';
+
+const findCallRecordingIdsMissingSummaryMock = vi.hoisted(() => vi.fn());
+const findCallRecordingIdsForCalendarEventsMock = vi.hoisted(() => vi.fn());
+const generateMissingCallRecordingSummariesMock = vi.hoisted(() => vi.fn());
+const isCallRecordingSummaryEnabledMock = vi.hoisted(() => vi.fn());
+
+vi.mock('twenty-client-sdk/core', () => ({
+ CoreApiClient: vi.fn(),
+}));
+
+vi.mock(
+ 'src/logic-functions/data/find-call-recording-ids-missing-summary.util',
+ () => ({
+ findCallRecordingIdsMissingSummary: findCallRecordingIdsMissingSummaryMock,
+ }),
+);
+
+vi.mock(
+ 'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util',
+ () => ({
+ findCallRecordingIdsForCalendarEvents:
+ findCallRecordingIdsForCalendarEventsMock,
+ }),
+);
+
+vi.mock(
+ 'src/logic-functions/flows/generate-missing-call-recording-summaries.util',
+ () => ({
+ generateMissingCallRecordingSummaries:
+ generateMissingCallRecordingSummariesMock,
+ }),
+);
+
+vi.mock(
+ 'src/logic-functions/utils/is-call-recording-summary-enabled.util',
+ () => ({
+ isCallRecordingSummaryEnabled: isCallRecordingSummaryEnabledMock,
+ }),
+);
+
+const buildRoutePayload = (
+ body: object | null,
+): RoutePayload<{ callRecordingIds?: string[]; calendarEventIds?: string[] }> =>
+ ({
+ body,
+ headers: {},
+ queryStringParameters: {},
+ pathParameters: {},
+ isBase64Encoded: false,
+ rawBody: undefined,
+ requestContext: { http: { method: 'POST', path: '/' } },
+ userWorkspaceId: null,
+ }) as never;
+
+const BATCH_RESULT = {
+ generatedCallRecordingIds: ['call-recording-1'],
+ failedCallRecordingIds: [],
+ erroredCallRecordingIds: [],
+ skippedCallRecordingIds: [],
+ remainingCallRecordingIds: [],
+ continuationRequested: false,
+};
+
+describe('generateCallRecordingSummariesHandler', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ isCallRecordingSummaryEnabledMock.mockReturnValue(true);
+ findCallRecordingIdsMissingSummaryMock.mockResolvedValue([]);
+ findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([]);
+ generateMissingCallRecordingSummariesMock.mockResolvedValue(BATCH_RESULT);
+ });
+
+ it('returns disabled without touching data when summaries are off', async () => {
+ isCallRecordingSummaryEnabledMock.mockReturnValue(false);
+
+ const result = await generateCallRecordingSummariesHandler(
+ buildRoutePayload(null),
+ );
+
+ expect(result).toEqual({ outcome: 'disabled' });
+ expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
+ expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ });
+
+ it('processes explicit call recording ids without sweeping', async () => {
+ const result = await generateCallRecordingSummariesHandler(
+ buildRoutePayload({ callRecordingIds: ['call-recording-1'] }),
+ );
+
+ expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
+ expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
+ expect.objectContaining({ callRecordingIds: ['call-recording-1'] }),
+ );
+ expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
+ expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled();
+ });
+
+ it('resolves calendar event ids to their call recordings', async () => {
+ findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([
+ 'call-recording-7',
+ ]);
+
+ await generateCallRecordingSummariesHandler(
+ buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
+ );
+
+ expect(findCallRecordingIdsForCalendarEventsMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { calendarEventIds: ['calendar-event-1'] },
+ );
+ expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
+ expect.objectContaining({ callRecordingIds: ['call-recording-7'] }),
+ );
+ expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
+ });
+
+ it('reports when the selected calendar events have no recordings instead of sweeping', async () => {
+ const result = await generateCallRecordingSummariesHandler(
+ buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
+ );
+
+ expect(result).toEqual({
+ outcome: 'no-call-recordings-for-calendar-events',
+ });
+ expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
+ expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ });
+
+ it('sweeps recordings missing a summary when no ids are given', async () => {
+ findCallRecordingIdsMissingSummaryMock.mockResolvedValue([
+ 'call-recording-1',
+ 'call-recording-2',
+ ]);
+
+ const result = await generateCallRecordingSummariesHandler(
+ buildRoutePayload(null),
+ );
+
+ expect(findCallRecordingIdsMissingSummaryMock).toHaveBeenCalledWith(
+ expect.anything(),
+ );
+ expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ callRecordingIds: ['call-recording-1', 'call-recording-2'],
+ }),
+ );
+ expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
+ });
+
+ it('does not sweep when an empty calendar event selection is sent', async () => {
+ const result = await generateCallRecordingSummariesHandler(
+ buildRoutePayload({ calendarEventIds: [] }),
+ );
+
+ expect(result).toEqual({ outcome: 'nothing-selected' });
+ expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
+ expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled();
+ expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ });
+
+ it('short-circuits an empty sweep without running the batch', async () => {
+ const result = await generateCallRecordingSummariesHandler(
+ buildRoutePayload({}),
+ );
+
+ expect(result).toEqual({ outcome: 'nothing-to-summarize' });
+ expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts
new file mode 100644
index 0000000000..791b95f387
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts
@@ -0,0 +1,62 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import postInstallLogicFunction, {
+ startCallRecordingSummaryBackfillOnInstallHandler,
+} from 'src/logic-functions/start-call-recording-summary-backfill-on-install';
+
+const requestCallRecordingSummariesBackfillMock = vi.hoisted(() => vi.fn());
+
+vi.mock(
+ 'src/logic-functions/data/request-call-recording-summaries-backfill.util',
+ () => ({
+ requestCallRecordingSummariesBackfill:
+ requestCallRecordingSummariesBackfillMock,
+ }),
+);
+
+describe('start-call-recording-summary-backfill-on-install', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ requestCallRecordingSummariesBackfillMock.mockResolvedValue(true);
+ });
+
+ it('is configured to run on app version upgrades', () => {
+ expect(postInstallLogicFunction.config).toEqual(
+ expect.objectContaining({
+ name: 'start-call-recording-summary-backfill-on-install',
+ timeoutSeconds: 30,
+ shouldRunOnVersionUpgrade: true,
+ }),
+ );
+ });
+
+ it('skips fresh installs', async () => {
+ const result = await startCallRecordingSummaryBackfillOnInstallHandler({
+ newVersion: '1.0.6',
+ });
+
+ expect(result).toEqual({ outcome: 'skipped-initial-install' });
+ expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled();
+ });
+
+ it('requests backfill on version upgrades', async () => {
+ const result = await startCallRecordingSummaryBackfillOnInstallHandler({
+ previousVersion: '1.0.5',
+ newVersion: '1.0.6',
+ });
+
+ expect(result).toEqual({ outcome: 'backfill-requested' });
+ expect(requestCallRecordingSummariesBackfillMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports failed backfill kickoff requests', async () => {
+ requestCallRecordingSummariesBackfillMock.mockResolvedValue(false);
+
+ const result = await startCallRecordingSummaryBackfillOnInstallHandler({
+ previousVersion: '1.0.5',
+ newVersion: '1.0.6',
+ });
+
+ expect(result).toEqual({ outcome: 'backfill-request-failed' });
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts
new file mode 100644
index 0000000000..58bd6971d6
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts
@@ -0,0 +1,129 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { summarizeCallRecordingHandler } from 'src/logic-functions/summarize-call-recording';
+
+const generateCallRecordingSummaryMock = vi.hoisted(() => vi.fn());
+
+vi.mock('twenty-client-sdk/core', () => ({
+ CoreApiClient: class {},
+}));
+
+vi.mock(
+ 'src/logic-functions/flows/generate-call-recording-summary.util',
+ () => ({
+ generateCallRecordingSummary: generateCallRecordingSummaryMock,
+ }),
+);
+
+const FAKE_OBJECT_METADATA = {
+ id: 'object-metadata-id',
+ nameSingular: 'callRecording',
+ namePlural: 'callRecordings',
+ labelSingular: 'Call Recording',
+ labelPlural: 'Call Recordings',
+ description: null,
+ icon: null,
+ universalIdentifier: 'call-recording-object',
+ applicationId: null,
+ dataSourceId: null,
+ standardOverrides: null,
+ isCustom: false,
+ isRemote: false,
+ isActive: true,
+ isSystem: false,
+ isUIEditable: false,
+ isUICreatable: false,
+ isAuditLogged: false,
+ isSearchable: false,
+ duplicateCriteria: null,
+ shortcut: null,
+ labelIdentifierFieldMetadataId: 'label-field-id',
+ imageIdentifierFieldMetadataId: null,
+ isLabelSyncedWithName: true,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ fieldIds: [],
+ indexMetadataIds: [],
+ viewIds: [],
+ applicationUniversalIdentifier: null,
+ labelIdentifierFieldMetadataUniversalIdentifier: 'label-field',
+ imageIdentifierFieldMetadataUniversalIdentifier: null,
+ fieldUniversalIdentifiers: [],
+ indexMetadataUniversalIdentifiers: [],
+ viewUniversalIdentifiers: [],
+} satisfies Parameters<
+ typeof summarizeCallRecordingHandler
+>[0]['objectMetadata'];
+
+const buildEvent = ({
+ name,
+ updatedFields,
+ recordId = 'call-recording-1',
+}: {
+ name: string;
+ updatedFields: string[];
+ recordId?: string;
+}): Parameters[0] => ({
+ name,
+ workspaceId: 'workspace-id',
+ objectMetadata: FAKE_OBJECT_METADATA,
+ recordId,
+ properties: { updatedFields },
+});
+
+describe('summarize-call-recording logic function', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ generateCallRecordingSummaryMock.mockResolvedValue({
+ outcome: 'generated',
+ });
+ });
+
+ it('generates a summary when the transcript field changed', async () => {
+ const result = await summarizeCallRecordingHandler(
+ buildEvent({
+ name: 'callRecording.updated',
+ updatedFields: ['transcript'],
+ }),
+ );
+
+ expect(generateCallRecordingSummaryMock).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ callRecordingId: 'call-recording-1',
+ requireCreatedByCallRecorder: true,
+ },
+ );
+ expect(result).toEqual({
+ callRecordingId: 'call-recording-1',
+ outcome: 'generated',
+ });
+ });
+
+ it('skips summary-only updates to avoid re-entrancy', async () => {
+ const result = await summarizeCallRecordingHandler(
+ buildEvent({
+ name: 'callRecording.updated',
+ updatedFields: ['summary'],
+ }),
+ );
+
+ expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled();
+ expect(result).toEqual({ skipped: true, reason: 'transcript unchanged' });
+ });
+
+ it('skips non-update events', async () => {
+ const result = await summarizeCallRecordingHandler(
+ buildEvent({
+ name: 'callRecording.created',
+ updatedFields: ['transcript'],
+ }),
+ );
+
+ expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ skipped: true,
+ reason: 'not a call recording update',
+ });
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts
new file mode 100644
index 0000000000..9a17c5724d
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME =
+ 'CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts
new file mode 100644
index 0000000000..5e14a34942
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-created-by-source.ts
@@ -0,0 +1,2 @@
+// Actor source the platform stamps on records created with an application token.
+export const CALL_RECORDER_CREATED_BY_SOURCE = 'APPLICATION';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts
new file mode 100644
index 0000000000..4f5ad7d54c
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/call-recorder-summary-enabled-env-var-name.ts
@@ -0,0 +1,2 @@
+export const CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME =
+ 'CALL_RECORDER_SUMMARY_ENABLED';
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts
new file mode 100644
index 0000000000..cc1dc6855d
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/default-call-recorder-summary-enabled.ts
@@ -0,0 +1 @@
+export const DEFAULT_CALL_RECORDER_SUMMARY_ENABLED = true;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts
new file mode 100644
index 0000000000..4b2f4807d6
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-for-summary.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { findCallRecordingForSummary } from 'src/logic-functions/data/find-call-recording-for-summary.util';
+
+describe('findCallRecordingForSummary', () => {
+ it('treats blank summary markdown as missing', async () => {
+ const query = vi.fn().mockResolvedValue({
+ callRecordings: {
+ edges: [
+ {
+ node: {
+ id: 'call-recording-1',
+ title: 'Weekly sync',
+ transcript: [],
+ summary: { markdown: '' },
+ createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
+ },
+ },
+ ],
+ },
+ });
+
+ const callRecording = await findCallRecordingForSummary(
+ { query } as never,
+ { id: 'call-recording-1' },
+ );
+
+ expect(callRecording?.summaryMarkdown).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts
new file mode 100644
index 0000000000..66059560e2
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-for-calendar-events.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
+import { findCallRecordingIdsForCalendarEvents } from 'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util';
+
+const buildConnection = (callRecordingIds: string[]) => ({
+ callRecordings: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ edges: callRecordingIds.map((callRecordingId) => ({
+ node: { id: callRecordingId },
+ })),
+ },
+});
+
+describe('findCallRecordingIdsForCalendarEvents', () => {
+ it('returns nothing without querying when no calendar event ids are given', async () => {
+ const query = vi.fn();
+
+ const callRecordingIds = await findCallRecordingIdsForCalendarEvents(
+ { query } as never,
+ { calendarEventIds: [] },
+ );
+
+ expect(callRecordingIds).toEqual([]);
+ expect(query).not.toHaveBeenCalled();
+ });
+
+ it('chunks calendar event ids before querying call recordings', async () => {
+ const calendarEventIds = Array.from(
+ { length: TWENTY_PAGE_SIZE + 1 },
+ (_, calendarEventIndex) => `calendar-event-${calendarEventIndex}`,
+ );
+ const query = vi
+ .fn()
+ .mockResolvedValueOnce(buildConnection(['call-recording-1']))
+ .mockResolvedValueOnce(buildConnection(['call-recording-2']));
+
+ const callRecordingIds = await findCallRecordingIdsForCalendarEvents(
+ { query } as never,
+ { calendarEventIds },
+ );
+
+ expect(callRecordingIds).toEqual(['call-recording-1', 'call-recording-2']);
+ expect(query).toHaveBeenCalledTimes(2);
+ expect(
+ query.mock.calls[0][0].callRecordings.__args.filter.calendarEventId.in,
+ ).toHaveLength(TWENTY_PAGE_SIZE);
+ expect(
+ query.mock.calls[1][0].callRecordings.__args.filter.calendarEventId.in,
+ ).toEqual(['calendar-event-100']);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts
new file mode 100644
index 0000000000..bcd262a983
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/find-call-recording-ids-missing-summary.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { findCallRecordingIdsMissingSummary } from 'src/logic-functions/data/find-call-recording-ids-missing-summary.util';
+
+const TRANSCRIPT = [
+ {
+ participant: { name: 'Alex' },
+ words: [{ text: 'Hello' }, { text: 'team' }],
+ },
+];
+
+const buildConnection = (
+ nodes: object[],
+ pageInfo: { hasNextPage: boolean; endCursor: string | null },
+) => ({
+ callRecordings: {
+ pageInfo,
+ edges: nodes.map((node) => ({ node })),
+ },
+});
+
+describe('findCallRecordingIdsMissingSummary', () => {
+ it('targets app-created completed recordings with a transcript', async () => {
+ let capturedFilter: unknown;
+ const query = vi.fn(async (queryArg: any) => {
+ capturedFilter = queryArg.callRecordings.__args.filter;
+
+ return buildConnection([], { hasNextPage: false, endCursor: null });
+ });
+
+ await findCallRecordingIdsMissingSummary({ query } as never);
+
+ expect(capturedFilter).toEqual({
+ status: { eq: 'COMPLETED' },
+ transcript: { is: 'NOT_NULL' },
+ createdBy: {
+ source: { eq: 'APPLICATION' },
+ name: { eq: 'Call Recorder' },
+ },
+ });
+ });
+
+ it('keeps only recordings missing a summary, newest first, across pages', async () => {
+ const query = vi
+ .fn()
+ .mockResolvedValueOnce(
+ buildConnection(
+ [
+ {
+ id: 'call-recording-old',
+ createdAt: '2026-06-01T10:00:00.000Z',
+ transcript: TRANSCRIPT,
+ summary: null,
+ },
+ {
+ id: 'call-recording-summarized',
+ createdAt: '2026-06-20T10:00:00.000Z',
+ transcript: TRANSCRIPT,
+ summary: { markdown: '## Overview\nDone.' },
+ },
+ ],
+ { hasNextPage: true, endCursor: 'cursor-1' },
+ ),
+ )
+ .mockResolvedValueOnce(
+ buildConnection(
+ [
+ {
+ id: 'call-recording-new',
+ createdAt: '2026-06-30T10:00:00.000Z',
+ transcript: TRANSCRIPT,
+ summary: { markdown: '' },
+ },
+ ],
+ { hasNextPage: false, endCursor: 'cursor-2' },
+ ),
+ );
+
+ const callRecordingIds = await findCallRecordingIdsMissingSummary({
+ query,
+ } as never);
+
+ expect(callRecordingIds).toEqual([
+ 'call-recording-new',
+ 'call-recording-old',
+ ]);
+ expect(query).toHaveBeenCalledTimes(2);
+ });
+
+ it('ignores recordings whose transcript cannot produce a summary prompt', async () => {
+ const query = vi.fn().mockResolvedValue(
+ buildConnection(
+ [
+ {
+ id: 'call-recording-empty-transcript',
+ createdAt: '2026-07-01T10:00:00.000Z',
+ transcript: [],
+ summary: null,
+ },
+ {
+ id: 'call-recording-empty-words',
+ createdAt: '2026-06-30T10:00:00.000Z',
+ transcript: [
+ {
+ participant: { name: 'Alex' },
+ words: [{ text: ' ' }],
+ },
+ ],
+ summary: null,
+ },
+ {
+ id: 'call-recording-valid',
+ createdAt: '2026-06-01T10:00:00.000Z',
+ transcript: TRANSCRIPT,
+ summary: null,
+ },
+ ],
+ { hasNextPage: false, endCursor: null },
+ ),
+ );
+
+ const callRecordingIds = await findCallRecordingIdsMissingSummary({
+ query,
+ } as never);
+
+ expect(callRecordingIds).toEqual(['call-recording-valid']);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts
new file mode 100644
index 0000000000..2446bc101e
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts
@@ -0,0 +1,46 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
+import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util';
+
+const postMock = vi.hoisted(() => vi.fn());
+
+vi.mock('twenty-client-sdk/rest', () => ({
+ RestApiClient: vi.fn(function RestApiClient() {
+ return {
+ post: postMock,
+ };
+ }),
+}));
+
+describe('requestCallRecordingSummariesBackfill', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ postMock.mockResolvedValue({});
+ });
+
+ it('posts an empty body to the summary generation route', async () => {
+ const result = await requestCallRecordingSummariesBackfill();
+
+ expect(result).toBe(true);
+ expect(postMock).toHaveBeenCalledWith(
+ `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
+ {},
+ { signal: expect.any(AbortSignal) },
+ );
+ });
+
+ it('treats timeout as a successfully flushed request', async () => {
+ const timeoutError = new Error('Timed out');
+ timeoutError.name = 'TimeoutError';
+ postMock.mockRejectedValue(timeoutError);
+
+ await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(true);
+ });
+
+ it('returns false when the kickoff request fails before flushing', async () => {
+ postMock.mockRejectedValue(new Error('Network failed'));
+
+ await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(false);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts
new file mode 100644
index 0000000000..d7be79c843
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-for-summary.util.ts
@@ -0,0 +1,54 @@
+import { isNull, isUndefined } from '@sniptt/guards';
+import { type CoreApiClient } from 'twenty-client-sdk/core';
+
+import { getString } from 'src/logic-functions/utils/get-string.util';
+
+type CallRecordingForSummary = {
+ id: string;
+ title: string | undefined;
+ transcript: unknown;
+ summaryMarkdown: string | undefined;
+ createdBy: { source: string | undefined; name: string | undefined };
+};
+
+export const findCallRecordingForSummary = async (
+ client: CoreApiClient,
+ { id }: { id: string },
+): Promise => {
+ const queryResult = await client.query({
+ callRecordings: {
+ __args: {
+ filter: { id: { eq: id } },
+ first: 1,
+ },
+ edges: {
+ node: {
+ id: true,
+ title: true,
+ transcript: true,
+ summary: { markdown: true },
+ createdBy: { source: true, name: true },
+ },
+ },
+ },
+ });
+
+ const node = queryResult.callRecordings?.edges?.[0]?.node;
+
+ if (isUndefined(node) || isNull(node)) {
+ return undefined;
+ }
+
+ return {
+ id: node.id,
+ title: getString(node.title),
+ transcript: node.transcript ?? undefined,
+ // Blank summaries are not usable; normalize them as missing so generation
+ // can repair blank summary records.
+ summaryMarkdown: getString(node.summary?.markdown),
+ createdBy: {
+ source: getString(node.createdBy?.source),
+ name: getString(node.createdBy?.name),
+ },
+ };
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts
new file mode 100644
index 0000000000..4397a3bec8
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-for-calendar-events.util.ts
@@ -0,0 +1,84 @@
+import { isUndefined } from '@sniptt/guards';
+import { type CoreApiClient } from 'twenty-client-sdk/core';
+
+import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
+import {
+ fetchAllNodes,
+ type ConnectionPage,
+} from 'src/logic-functions/data/fetch-all-nodes.util';
+
+const CALENDAR_EVENT_ID_BATCH_SIZE = TWENTY_PAGE_SIZE;
+
+type CallRecordingIdNode = {
+ id: string;
+};
+
+const getCalendarEventIdBatches = (calendarEventIds: string[]): string[][] => {
+ const uniqueCalendarEventIds = [...new Set(calendarEventIds)];
+ const calendarEventIdBatches: string[][] = [];
+
+ for (
+ let batchStartIndex = 0;
+ batchStartIndex < uniqueCalendarEventIds.length;
+ batchStartIndex += CALENDAR_EVENT_ID_BATCH_SIZE
+ ) {
+ calendarEventIdBatches.push(
+ uniqueCalendarEventIds.slice(
+ batchStartIndex,
+ batchStartIndex + CALENDAR_EVENT_ID_BATCH_SIZE,
+ ),
+ );
+ }
+
+ return calendarEventIdBatches;
+};
+
+export const findCallRecordingIdsForCalendarEvents = async (
+ client: CoreApiClient,
+ { calendarEventIds }: { calendarEventIds: string[] },
+): Promise => {
+ if (calendarEventIds.length === 0) {
+ return [];
+ }
+
+ const callRecordingIds: string[] = [];
+
+ for (const calendarEventIdBatch of getCalendarEventIdBatches(
+ calendarEventIds,
+ )) {
+ const callRecordingNodes = await fetchAllNodes(
+ async (afterCursor) => {
+ const queryResult = await client.query({
+ callRecordings: {
+ __args: {
+ filter: {
+ calendarEventId: { in: calendarEventIdBatch },
+ },
+ first: TWENTY_PAGE_SIZE,
+ ...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
+ },
+ pageInfo: {
+ hasNextPage: true,
+ endCursor: true,
+ },
+ edges: {
+ node: {
+ id: true,
+ },
+ },
+ },
+ });
+
+ return queryResult.callRecordings as
+ | ConnectionPage
+ | undefined;
+ },
+ );
+
+ for (const callRecordingNode of callRecordingNodes) {
+ callRecordingIds.push(callRecordingNode.id);
+ }
+ }
+
+ return [...new Set(callRecordingIds)];
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts
new file mode 100644
index 0000000000..d9aef3f815
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/find-call-recording-ids-missing-summary.util.ts
@@ -0,0 +1,76 @@
+import { isUndefined } from '@sniptt/guards';
+import { type CoreApiClient } from 'twenty-client-sdk/core';
+
+import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
+import { CALL_RECORDER_CREATED_BY_SOURCE } from 'src/logic-functions/constants/call-recorder-created-by-source';
+import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
+import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
+import {
+ fetchAllNodes,
+ type ConnectionPage,
+} from 'src/logic-functions/data/fetch-all-nodes.util';
+import { buildCallRecordingSummaryPrompt } from 'src/logic-functions/domain/build-call-recording-summary-prompt.util';
+import { getString } from 'src/logic-functions/utils/get-string.util';
+
+type CallRecordingSummaryStateNode = {
+ id: string;
+ createdAt?: string | null;
+ transcript?: unknown;
+ summary?: { markdown?: string | null } | null;
+};
+
+export const findCallRecordingIdsMissingSummary = async (
+ client: CoreApiClient,
+): Promise => {
+ const callRecordingNodes = await fetchAllNodes(
+ async (afterCursor) => {
+ const queryResult = await client.query({
+ callRecordings: {
+ __args: {
+ filter: {
+ status: { eq: CallRecordingStatus.COMPLETED },
+ transcript: { is: 'NOT_NULL' },
+ // Never sweep recordings another app or a user created β agent
+ // runs are billed, so the unattended path only spends on ours.
+ createdBy: {
+ source: { eq: CALL_RECORDER_CREATED_BY_SOURCE },
+ name: { eq: APP_DISPLAY_NAME },
+ },
+ },
+ first: TWENTY_PAGE_SIZE,
+ ...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
+ },
+ pageInfo: {
+ hasNextPage: true,
+ endCursor: true,
+ },
+ edges: {
+ node: {
+ id: true,
+ createdAt: true,
+ transcript: true,
+ summary: { markdown: true },
+ },
+ },
+ },
+ });
+
+ return queryResult.callRecordings as
+ | ConnectionPage
+ | undefined;
+ },
+ );
+
+ return callRecordingNodes
+ .filter(
+ (callRecording) =>
+ isUndefined(getString(callRecording.summary?.markdown)) &&
+ buildCallRecordingSummaryPrompt({
+ transcript: callRecording.transcript,
+ }) !== undefined,
+ )
+ .sort((left, right) =>
+ (right.createdAt ?? '').localeCompare(left.createdAt ?? ''),
+ )
+ .map((callRecording) => callRecording.id);
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts
new file mode 100644
index 0000000000..15a2e4b669
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts
@@ -0,0 +1,35 @@
+import { RestApiClient } from 'twenty-client-sdk/rest';
+
+import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
+
+const BACKFILL_KICKOFF_FLUSH_MS = 5_000;
+
+export const requestCallRecordingSummariesBackfill =
+ async (): Promise => {
+ const client = new RestApiClient();
+
+ try {
+ await client.post(
+ `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
+ {},
+ { signal: AbortSignal.timeout(BACKFILL_KICKOFF_FLUSH_MS) },
+ );
+
+ return true;
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (error.name === 'TimeoutError' || error.name === 'AbortError')
+ ) {
+ return true;
+ }
+
+ if (process.env.NODE_ENV !== 'test') {
+ console.error(
+ `[call-recorder] summary backfill kickoff failed to fire: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+
+ return false;
+ }
+ };
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts
new file mode 100644
index 0000000000..17787ca416
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts
@@ -0,0 +1,38 @@
+import { RestApiClient } from 'twenty-client-sdk/rest';
+
+import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
+
+const CONTINUATION_FLUSH_MS = 5_000;
+
+export const requestCallRecordingSummariesContinuation = async ({
+ callRecordingIds,
+}: {
+ callRecordingIds: string[];
+}): Promise => {
+ const client = new RestApiClient();
+
+ try {
+ await client.post(
+ `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
+ { callRecordingIds },
+ { signal: AbortSignal.timeout(CONTINUATION_FLUSH_MS) },
+ );
+
+ return true;
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (error.name === 'TimeoutError' || error.name === 'AbortError')
+ ) {
+ return true;
+ }
+
+ if (process.env.NODE_ENV !== 'test') {
+ console.error(
+ `[call-recorder] summary generation continuation failed to fire: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+
+ return false;
+ }
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts
new file mode 100644
index 0000000000..91dbe63dc0
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-call-recording-summary-prompt.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildCallRecordingSummaryPrompt } from 'src/logic-functions/domain/build-call-recording-summary-prompt.util';
+
+const TRANSCRIPT = [
+ {
+ participant: { name: 'Alex' },
+ words: [{ text: 'Hello' }, { text: 'there' }],
+ },
+ {
+ participant: { name: 'Sam' },
+ words: [{ text: 'Hi' }, { text: 'Alex' }],
+ },
+];
+
+describe('buildCallRecordingSummaryPrompt', () => {
+ it('flattens entries into speaker-labelled lines', () => {
+ expect(
+ buildCallRecordingSummaryPrompt({ transcript: TRANSCRIPT }),
+ ).toContain('Transcript:\nAlex: Hello there\nSam: Hi Alex');
+ });
+
+ it('appends the workspace admin instructions when provided', () => {
+ expect(
+ buildCallRecordingSummaryPrompt({
+ transcript: TRANSCRIPT,
+ additionalSummaryPrompt: ' Write short sales notes. ',
+ }),
+ ).toBe(
+ 'Additional instructions from the workspace admin:\nWrite short sales notes.\n\nTranscript:\nAlex: Hello there\nSam: Hi Alex',
+ );
+ });
+
+ it('prefixes a [mm:ss] (or h:mm:ss) timestamp when the entry carries one', () => {
+ expect(
+ buildCallRecordingSummaryPrompt({
+ transcript: [
+ {
+ participant: { name: 'Alex' },
+ words: [
+ { text: 'Hello', start_timestamp: { relative: 5 } },
+ { text: 'there', start_timestamp: { relative: 6 } },
+ ],
+ },
+ {
+ participant: { name: 'Sam' },
+ words: [{ text: 'Later', start_timestamp: { relative: 3725 } }],
+ },
+ ],
+ }),
+ ).toContain('Transcript:\n[0:05] Alex: Hello there\n[1:02:05] Sam: Later');
+ });
+
+ it('includes the meeting title when provided', () => {
+ expect(
+ buildCallRecordingSummaryPrompt({
+ transcript: TRANSCRIPT,
+ title: 'Weekly sync',
+ }),
+ ).toContain(
+ 'Meeting title: Weekly sync\n\nTranscript:\nAlex: Hello there\nSam: Hi Alex',
+ );
+ });
+
+ it('falls back to a placeholder speaker name', () => {
+ expect(
+ buildCallRecordingSummaryPrompt({
+ transcript: [{ participant: {}, words: [{ text: 'Hey' }] }],
+ }),
+ ).toContain('Transcript:\nUnknown speaker: Hey');
+ });
+
+ it('skips entries without usable words', () => {
+ expect(
+ buildCallRecordingSummaryPrompt({
+ transcript: [
+ { participant: { name: 'Alex' }, words: [] },
+ { participant: { name: 'Sam' }, words: [{ text: 'Real' }] },
+ ],
+ }),
+ ).toContain('Transcript:\nSam: Real');
+ });
+
+ it('returns undefined when there is no usable dialogue', () => {
+ expect(buildCallRecordingSummaryPrompt({ transcript: [] })).toBeUndefined();
+ expect(
+ buildCallRecordingSummaryPrompt({ transcript: { status: 'PENDING' } }),
+ ).toBeUndefined();
+ expect(
+ buildCallRecordingSummaryPrompt({
+ transcript: [{ participant: { name: 'A' }, words: [{ text: ' ' }] }],
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts
new file mode 100644
index 0000000000..5e3cbdecf0
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/extract-call-recording-summary-markdown.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+
+import { extractCallRecordingSummaryMarkdown } from 'src/logic-functions/domain/extract-call-recording-summary-markdown.util';
+
+describe('extractCallRecordingSummaryMarkdown', () => {
+ it('returns the trimmed response markdown on success', () => {
+ expect(
+ extractCallRecordingSummaryMarkdown({
+ success: true,
+ error: null,
+ result: { response: ' ## Overview\nGreat call. ' },
+ }),
+ ).toBe('## Overview\nGreat call.');
+ });
+
+ it('returns undefined when the run failed', () => {
+ expect(
+ extractCallRecordingSummaryMarkdown({
+ success: false,
+ error: 'no more available credits',
+ result: null,
+ }),
+ ).toBeUndefined();
+ });
+
+ it('returns undefined when the response is empty or missing', () => {
+ expect(
+ extractCallRecordingSummaryMarkdown({
+ success: true,
+ error: null,
+ result: { response: ' ' },
+ }),
+ ).toBeUndefined();
+ expect(
+ extractCallRecordingSummaryMarkdown({
+ success: true,
+ error: null,
+ result: {},
+ }),
+ ).toBeUndefined();
+ });
+
+ it('returns the no-summary verdict verbatim', () => {
+ expect(
+ extractCallRecordingSummaryMarkdown({
+ success: true,
+ error: null,
+ result: { response: 'No summary available.' },
+ }),
+ ).toBe('No summary available.');
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts
new file mode 100644
index 0000000000..0b36beef4a
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-call-recording-created-by-call-recorder.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+
+import { isCallRecordingCreatedByCallRecorder } from 'src/logic-functions/domain/is-call-recording-created-by-call-recorder.util';
+
+describe('isCallRecordingCreatedByCallRecorder', () => {
+ it('accepts the app-stamped actor', () => {
+ expect(
+ isCallRecordingCreatedByCallRecorder({
+ source: 'APPLICATION',
+ name: 'Call Recorder',
+ }),
+ ).toBe(true);
+ });
+
+ it('rejects records created by users', () => {
+ expect(
+ isCallRecordingCreatedByCallRecorder({
+ source: 'MANUAL',
+ name: 'Alex',
+ }),
+ ).toBe(false);
+ });
+
+ it('rejects records created by another application', () => {
+ expect(
+ isCallRecordingCreatedByCallRecorder({
+ source: 'APPLICATION',
+ name: 'Fireflies',
+ }),
+ ).toBe(false);
+ });
+
+ it('rejects records with no actor data', () => {
+ expect(isCallRecordingCreatedByCallRecorder({})).toBe(false);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts
new file mode 100644
index 0000000000..542792448e
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/is-real-transcript.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { isRealTranscript } from 'src/logic-functions/domain/is-real-transcript.util';
+
+describe('isRealTranscript', () => {
+ it('is true for a non-empty diarized array', () => {
+ expect(isRealTranscript([{ participant: { name: 'A' }, words: [] }])).toBe(
+ true,
+ );
+ });
+
+ it('is false for an empty array', () => {
+ expect(isRealTranscript([])).toBe(false);
+ });
+
+ it('is false for PENDING/FAILED markers', () => {
+ expect(
+ isRealTranscript({ status: 'PENDING', recallTranscriptId: 'x' }),
+ ).toBe(false);
+ expect(isRealTranscript({ status: 'FAILED' })).toBe(false);
+ });
+
+ it('is false when unset', () => {
+ expect(isRealTranscript(null)).toBe(false);
+ expect(isRealTranscript(undefined)).toBe(false);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts
new file mode 100644
index 0000000000..ea2fbd4e48
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/build-call-recording-summary-prompt.util.ts
@@ -0,0 +1,82 @@
+import { isArray, isNumber } from '@sniptt/guards';
+
+import { asRecord } from 'src/logic-functions/utils/as-record.util';
+import { formatSecondsAsClockTimestamp } from 'src/logic-functions/utils/format-seconds-as-clock-timestamp.util';
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+
+const UNKNOWN_SPEAKER = 'Unknown speaker';
+
+const readEntryStartSeconds = (words: unknown[]): number | undefined => {
+ for (const word of words) {
+ const relative = asRecord(asRecord(word)?.start_timestamp)?.relative;
+
+ if (isNumber(relative) && Number.isFinite(relative)) {
+ return relative;
+ }
+ }
+
+ return undefined;
+};
+
+const buildTranscriptLine = (entry: unknown): string | undefined => {
+ const record = asRecord(entry);
+
+ if (record === undefined || !isArray(record.words)) {
+ return undefined;
+ }
+
+ const text = record.words
+ .map((word) => asRecord(word)?.text)
+ .filter(isNonEmptyString)
+ .map((word) => word.trim())
+ .join(' ')
+ .trim();
+
+ if (!isNonEmptyString(text)) {
+ return undefined;
+ }
+
+ const participantName = asRecord(record.participant)?.name;
+ const speakerName = isNonEmptyString(participantName)
+ ? participantName.trim()
+ : UNKNOWN_SPEAKER;
+
+ const startSeconds = readEntryStartSeconds(record.words);
+ const timestamp =
+ startSeconds === undefined
+ ? ''
+ : `[${formatSecondsAsClockTimestamp(startSeconds)}] `;
+
+ return `${timestamp}${speakerName}: ${text}`;
+};
+
+// The summarization instructions live in the agent's own prompt; the built
+// prompt only carries the workspace admin's additional instructions on top.
+export const buildCallRecordingSummaryPrompt = ({
+ transcript,
+ title,
+ additionalSummaryPrompt,
+}: {
+ transcript: unknown;
+ title?: string;
+ additionalSummaryPrompt?: string;
+}): string | undefined => {
+ if (!isArray(transcript)) {
+ return undefined;
+ }
+
+ const lines = transcript.map(buildTranscriptLine).filter(isNonEmptyString);
+
+ if (lines.length === 0) {
+ return undefined;
+ }
+
+ const additionalInstructions = isNonEmptyString(additionalSummaryPrompt)
+ ? `Additional instructions from the workspace admin:\n${additionalSummaryPrompt.trim()}\n\n`
+ : '';
+ const header = isNonEmptyString(title)
+ ? `Meeting title: ${title.trim()}\n\n`
+ : '';
+
+ return `${additionalInstructions}${header}Transcript:\n${lines.join('\n')}`;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts
new file mode 100644
index 0000000000..740362e106
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/extract-call-recording-summary-markdown.util.ts
@@ -0,0 +1,20 @@
+import { type RunAgentResult } from 'twenty-sdk/logic-function';
+
+import { asRecord } from 'src/logic-functions/utils/as-record.util';
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+
+export const extractCallRecordingSummaryMarkdown = (
+ agentResult: RunAgentResult,
+): string | undefined => {
+ if (!agentResult.success) {
+ return undefined;
+ }
+
+ const response = asRecord(agentResult.result)?.response;
+
+ if (!isNonEmptyString(response)) {
+ return undefined;
+ }
+
+ return response.trim();
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts
new file mode 100644
index 0000000000..4d6096b55b
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-call-recording-created-by-call-recorder.util.ts
@@ -0,0 +1,12 @@
+import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
+import { CALL_RECORDER_CREATED_BY_SOURCE } from 'src/logic-functions/constants/call-recorder-created-by-source';
+
+export const isCallRecordingCreatedByCallRecorder = (createdBy: {
+ source?: string;
+ name?: string;
+}): boolean =>
+ createdBy.source === CALL_RECORDER_CREATED_BY_SOURCE &&
+ // TODO: Replace this display-name coupling with typed app provenance after a
+ // core actor change stamps the application universal identifier on app-created
+ // records.
+ createdBy.name === APP_DISPLAY_NAME;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts
new file mode 100644
index 0000000000..02e526570d
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/is-real-transcript.util.ts
@@ -0,0 +1,4 @@
+import { isArray } from '@sniptt/guards';
+
+export const isRealTranscript = (transcript: unknown): boolean =>
+ isArray(transcript) && transcript.length > 0;
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts
new file mode 100644
index 0000000000..b80d5014ec
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts
@@ -0,0 +1,261 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { CoreApiClient } from 'twenty-client-sdk/core';
+
+import { generateCallRecordingSummary } from 'src/logic-functions/flows/generate-call-recording-summary.util';
+
+const runAgentMock = vi.hoisted(() => vi.fn());
+const findCallRecordingForSummaryMock = vi.hoisted(() => vi.fn());
+const updateCallRecordingMock = vi.hoisted(() => vi.fn());
+const getCallRecorderAdditionalSummaryPromptMock = vi.hoisted(() => vi.fn());
+const isCallRecordingSummaryEnabledMock = vi.hoisted(() => vi.fn());
+
+vi.mock('twenty-sdk/logic-function', () => ({
+ runAgent: runAgentMock,
+}));
+
+vi.mock(
+ 'src/logic-functions/data/find-call-recording-for-summary.util',
+ () => ({
+ findCallRecordingForSummary: findCallRecordingForSummaryMock,
+ }),
+);
+
+vi.mock('src/logic-functions/data/update-call-recording.util', () => ({
+ updateCallRecording: updateCallRecordingMock,
+}));
+
+vi.mock(
+ 'src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util',
+ () => ({
+ getCallRecorderAdditionalSummaryPrompt:
+ getCallRecorderAdditionalSummaryPromptMock,
+ }),
+);
+
+vi.mock(
+ 'src/logic-functions/utils/is-call-recording-summary-enabled.util',
+ () => ({
+ isCallRecordingSummaryEnabled: isCallRecordingSummaryEnabledMock,
+ }),
+);
+
+const TRANSCRIPT = [
+ {
+ participant: { name: 'Alex' },
+ words: [{ text: 'Hello' }, { text: 'team' }],
+ },
+];
+
+const CLIENT: CoreApiClient = Object.assign(
+ Object.create(CoreApiClient.prototype),
+ {
+ mutation: vi.fn(),
+ query: vi.fn(),
+ },
+);
+
+describe('generateCallRecordingSummary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getCallRecorderAdditionalSummaryPromptMock.mockReturnValue(undefined);
+ isCallRecordingSummaryEnabledMock.mockReturnValue(true);
+ findCallRecordingForSummaryMock.mockResolvedValue({
+ id: 'call-recording-1',
+ title: 'Weekly sync',
+ transcript: TRANSCRIPT,
+ summaryMarkdown: undefined,
+ createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
+ });
+ updateCallRecordingMock.mockResolvedValue(undefined);
+ runAgentMock.mockResolvedValue({
+ success: true,
+ error: null,
+ result: { response: '## Overview\nGood call.' },
+ });
+ });
+
+ it('skips when summaries are disabled', async () => {
+ isCallRecordingSummaryEnabledMock.mockReturnValue(false);
+
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(result).toEqual({ outcome: 'disabled' });
+ expect(findCallRecordingForSummaryMock).not.toHaveBeenCalled();
+ expect(runAgentMock).not.toHaveBeenCalled();
+ });
+
+ it('skips when there is no real transcript', async () => {
+ findCallRecordingForSummaryMock.mockResolvedValue({
+ id: 'call-recording-1',
+ title: undefined,
+ transcript: { status: 'PENDING' },
+ summaryMarkdown: undefined,
+ });
+
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(result).toEqual({ outcome: 'no-transcript' });
+ expect(runAgentMock).not.toHaveBeenCalled();
+ });
+
+ it('skips when a summary already exists', async () => {
+ findCallRecordingForSummaryMock.mockResolvedValue({
+ id: 'call-recording-1',
+ title: undefined,
+ transcript: TRANSCRIPT,
+ summaryMarkdown: '## Overview\nAlready here.',
+ });
+
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(result).toEqual({ outcome: 'already-summarized' });
+ expect(runAgentMock).not.toHaveBeenCalled();
+ });
+
+ it('skips recordings another actor created when the app-created gate is on', async () => {
+ findCallRecordingForSummaryMock.mockResolvedValue({
+ id: 'call-recording-1',
+ title: undefined,
+ transcript: TRANSCRIPT,
+ summaryMarkdown: undefined,
+ createdBy: { source: 'MANUAL', name: 'Alex' },
+ });
+
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ requireCreatedByCallRecorder: true,
+ });
+
+ expect(result).toEqual({ outcome: 'not-app-recording' });
+ expect(runAgentMock).not.toHaveBeenCalled();
+ });
+
+ it('generates for app-created recordings when the app-created gate is on', async () => {
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ requireCreatedByCallRecorder: true,
+ });
+
+ expect(result).toEqual({ outcome: 'generated' });
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
+ expect(updateCallRecordingMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('generates for recordings another actor created when explicitly requested', async () => {
+ findCallRecordingForSummaryMock.mockResolvedValue({
+ id: 'call-recording-1',
+ title: undefined,
+ transcript: TRANSCRIPT,
+ summaryMarkdown: undefined,
+ createdBy: { source: 'MANUAL', name: 'Alex' },
+ });
+
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(result).toEqual({ outcome: 'generated' });
+ });
+
+ it('runs the agent and stores the summary markdown on the happy path', async () => {
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(result).toEqual({ outcome: 'generated' });
+ expect(runAgentMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ prompt: expect.stringContaining('Alex: Hello team'),
+ }),
+ );
+ expect(updateCallRecordingMock).toHaveBeenCalledWith(CLIENT, {
+ id: 'call-recording-1',
+ data: {
+ summary: { blocknote: null, markdown: '## Overview\nGood call.' },
+ },
+ });
+ });
+
+ it('appends the workspace admin instructions to the agent prompt', async () => {
+ getCallRecorderAdditionalSummaryPromptMock.mockReturnValue(
+ 'Write terse notes.',
+ );
+
+ await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(runAgentMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ prompt: expect.stringContaining(
+ 'Additional instructions from the workspace admin:\nWrite terse notes.\n\nMeeting title: Weekly sync',
+ ),
+ }),
+ );
+ });
+
+ it('stores the no-summary verdict verbatim so the run is terminal', async () => {
+ runAgentMock.mockResolvedValue({
+ success: true,
+ error: null,
+ result: { response: 'No summary available.' },
+ });
+
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(result).toEqual({ outcome: 'generated' });
+ expect(updateCallRecordingMock).toHaveBeenCalledWith(CLIENT, {
+ id: 'call-recording-1',
+ data: { summary: { blocknote: null, markdown: 'No summary available.' } },
+ });
+ });
+
+ it('stores nothing when the agent run fails', async () => {
+ runAgentMock.mockResolvedValue({
+ success: false,
+ error: 'no more available credits',
+ result: null,
+ });
+
+ const result = await generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ });
+
+ expect(result).toEqual({ outcome: 'empty-summary' });
+ expect(updateCallRecordingMock).not.toHaveBeenCalled();
+ });
+
+ it('propagates agent errors without writing a summary', async () => {
+ runAgentMock.mockRejectedValue(new Error('Agent execution failed'));
+
+ await expect(
+ generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ }),
+ ).rejects.toThrow('Agent execution failed');
+
+ expect(updateCallRecordingMock).not.toHaveBeenCalled();
+ });
+
+ it('propagates summary write errors', async () => {
+ updateCallRecordingMock.mockRejectedValue(
+ new Error('Summary write failed'),
+ );
+
+ await expect(
+ generateCallRecordingSummary(CLIENT, {
+ callRecordingId: 'call-recording-1',
+ }),
+ ).rejects.toThrow('Summary write failed');
+
+ expect(updateCallRecordingMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts
new file mode 100644
index 0000000000..f3ed3f37e4
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts
@@ -0,0 +1,188 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { CoreApiClient } from 'twenty-client-sdk/core';
+
+import { generateMissingCallRecordingSummaries } from 'src/logic-functions/flows/generate-missing-call-recording-summaries.util';
+
+const generateCallRecordingSummaryMock = vi.hoisted(() => vi.fn());
+const requestContinuationMock = vi.hoisted(() => vi.fn());
+
+vi.mock(
+ 'src/logic-functions/flows/generate-call-recording-summary.util',
+ () => ({
+ generateCallRecordingSummary: generateCallRecordingSummaryMock,
+ }),
+);
+
+vi.mock(
+ 'src/logic-functions/data/request-call-recording-summaries-continuation.util',
+ () => ({
+ requestCallRecordingSummariesContinuation: requestContinuationMock,
+ }),
+);
+
+const CLIENT: CoreApiClient = Object.assign(
+ Object.create(CoreApiClient.prototype),
+ {
+ mutation: vi.fn(),
+ query: vi.fn(),
+ },
+);
+
+// Each processed item advances the clock by ITEM_MS across the three
+// getNowMs reads of one loop iteration.
+const buildClock = (itemMs: number) => {
+ let nowMs = 0;
+ let reads = 0;
+
+ return () => {
+ reads += 1;
+ if (reads % 3 === 2) {
+ nowMs += itemMs;
+ }
+
+ return nowMs;
+ };
+};
+
+describe('generateMissingCallRecordingSummaries', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ generateCallRecordingSummaryMock.mockResolvedValue({
+ outcome: 'generated',
+ });
+ requestContinuationMock.mockResolvedValue(true);
+ });
+
+ it('processes every id and skips the continuation when the budget allows', async () => {
+ const result = await generateMissingCallRecordingSummaries({
+ client: CLIENT,
+ callRecordingIds: ['call-recording-1', 'call-recording-2'],
+ deadlineAtMs: 1_000_000,
+ getNowMs: buildClock(10),
+ });
+
+ expect(result).toEqual({
+ generatedCallRecordingIds: ['call-recording-1', 'call-recording-2'],
+ failedCallRecordingIds: [],
+ erroredCallRecordingIds: [],
+ skippedCallRecordingIds: [],
+ remainingCallRecordingIds: [],
+ continuationRequested: false,
+ });
+ expect(requestContinuationMock).not.toHaveBeenCalled();
+ });
+
+ it('always processes at least one id even when the deadline already passed', async () => {
+ const result = await generateMissingCallRecordingSummaries({
+ client: CLIENT,
+ callRecordingIds: ['call-recording-1', 'call-recording-2'],
+ deadlineAtMs: 0,
+ getNowMs: buildClock(10),
+ });
+
+ expect(result.generatedCallRecordingIds).toEqual(['call-recording-1']);
+ expect(result.remainingCallRecordingIds).toEqual(['call-recording-2']);
+ expect(result.continuationRequested).toBe(true);
+ expect(requestContinuationMock).toHaveBeenCalledWith({
+ callRecordingIds: ['call-recording-2'],
+ });
+ });
+
+ it('stops when the next item would overrun the deadline and hands off the rest', async () => {
+ // 100ms per item against a 250ms deadline: two items fit, the third
+ // projected finish (200 + 100) exceeds 250 only after the second item.
+ const result = await generateMissingCallRecordingSummaries({
+ client: CLIENT,
+ callRecordingIds: [
+ 'call-recording-1',
+ 'call-recording-2',
+ 'call-recording-3',
+ 'call-recording-4',
+ ],
+ deadlineAtMs: 250,
+ getNowMs: buildClock(100),
+ });
+
+ expect(result.generatedCallRecordingIds).toEqual([
+ 'call-recording-1',
+ 'call-recording-2',
+ ]);
+ expect(result.remainingCallRecordingIds).toEqual([
+ 'call-recording-3',
+ 'call-recording-4',
+ ]);
+ expect(requestContinuationMock).toHaveBeenCalledWith({
+ callRecordingIds: ['call-recording-3', 'call-recording-4'],
+ });
+ });
+
+ it('separates empty summaries from thrown generation errors', async () => {
+ generateCallRecordingSummaryMock
+ .mockResolvedValueOnce({ outcome: 'empty-summary' })
+ .mockRejectedValueOnce(new Error('agent exploded'))
+ .mockResolvedValueOnce({ outcome: 'generated' });
+
+ const result = await generateMissingCallRecordingSummaries({
+ client: CLIENT,
+ callRecordingIds: [
+ 'call-recording-1',
+ 'call-recording-2',
+ 'call-recording-3',
+ ],
+ deadlineAtMs: 1_000_000,
+ getNowMs: buildClock(10),
+ });
+
+ expect(result).toEqual({
+ generatedCallRecordingIds: ['call-recording-3'],
+ failedCallRecordingIds: ['call-recording-1'],
+ erroredCallRecordingIds: ['call-recording-2'],
+ skippedCallRecordingIds: [],
+ remainingCallRecordingIds: [],
+ continuationRequested: false,
+ });
+ });
+
+ it('records skip outcomes without treating them as failures', async () => {
+ generateCallRecordingSummaryMock
+ .mockResolvedValueOnce({ outcome: 'already-summarized' })
+ .mockResolvedValueOnce({ outcome: 'no-transcript' });
+
+ const result = await generateMissingCallRecordingSummaries({
+ client: CLIENT,
+ callRecordingIds: ['call-recording-1', 'call-recording-2'],
+ deadlineAtMs: 1_000_000,
+ getNowMs: buildClock(10),
+ });
+
+ expect(result.skippedCallRecordingIds).toEqual([
+ 'call-recording-1',
+ 'call-recording-2',
+ ]);
+ expect(result.failedCallRecordingIds).toEqual([]);
+ expect(result.erroredCallRecordingIds).toEqual([]);
+ });
+
+ it('stops spending immediately when summaries get disabled mid-run', async () => {
+ generateCallRecordingSummaryMock
+ .mockResolvedValueOnce({ outcome: 'generated' })
+ .mockResolvedValueOnce({ outcome: 'disabled' });
+
+ const result = await generateMissingCallRecordingSummaries({
+ client: CLIENT,
+ callRecordingIds: [
+ 'call-recording-1',
+ 'call-recording-2',
+ 'call-recording-3',
+ ],
+ deadlineAtMs: 1_000_000,
+ getNowMs: buildClock(10),
+ });
+
+ expect(result.generatedCallRecordingIds).toEqual(['call-recording-1']);
+ expect(result.remainingCallRecordingIds).toEqual(['call-recording-3']);
+ expect(result.continuationRequested).toBe(false);
+ expect(requestContinuationMock).not.toHaveBeenCalled();
+ expect(generateCallRecordingSummaryMock).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts
new file mode 100644
index 0000000000..f7c8455f6c
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary-result.type.ts
@@ -0,0 +1,9 @@
+export type GenerateCallRecordingSummaryResult = {
+ outcome:
+ | 'disabled'
+ | 'no-transcript'
+ | 'not-app-recording'
+ | 'already-summarized'
+ | 'empty-summary'
+ | 'generated';
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts
new file mode 100644
index 0000000000..f829367b7a
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-call-recording-summary.util.ts
@@ -0,0 +1,76 @@
+import { type CoreApiClient } from 'twenty-client-sdk/core';
+import { runAgent } from 'twenty-sdk/logic-function';
+
+import { CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summarizer-agent-universal-identifier';
+import { findCallRecordingForSummary } from 'src/logic-functions/data/find-call-recording-for-summary.util';
+import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util';
+import { buildCallRecordingSummaryPrompt } from 'src/logic-functions/domain/build-call-recording-summary-prompt.util';
+import { extractCallRecordingSummaryMarkdown } from 'src/logic-functions/domain/extract-call-recording-summary-markdown.util';
+import { isCallRecordingCreatedByCallRecorder } from 'src/logic-functions/domain/is-call-recording-created-by-call-recorder.util';
+import { isRealTranscript } from 'src/logic-functions/domain/is-real-transcript.util';
+import { type GenerateCallRecordingSummaryResult } from 'src/logic-functions/flows/generate-call-recording-summary-result.type';
+import { getCallRecorderAdditionalSummaryPrompt } from 'src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util';
+import { isCallRecordingSummaryEnabled } from 'src/logic-functions/utils/is-call-recording-summary-enabled.util';
+
+export const generateCallRecordingSummary = async (
+ client: CoreApiClient,
+ {
+ callRecordingId,
+ requireCreatedByCallRecorder = false,
+ }: { callRecordingId: string; requireCreatedByCallRecorder?: boolean },
+): Promise => {
+ if (!isCallRecordingSummaryEnabled()) {
+ return { outcome: 'disabled' };
+ }
+
+ const callRecording = await findCallRecordingForSummary(client, {
+ id: callRecordingId,
+ });
+
+ if (
+ callRecording === undefined ||
+ !isRealTranscript(callRecording.transcript)
+ ) {
+ return { outcome: 'no-transcript' };
+ }
+
+ if (
+ requireCreatedByCallRecorder &&
+ !isCallRecordingCreatedByCallRecorder(callRecording.createdBy)
+ ) {
+ return { outcome: 'not-app-recording' };
+ }
+
+ if (callRecording.summaryMarkdown !== undefined) {
+ return { outcome: 'already-summarized' };
+ }
+
+ const prompt = buildCallRecordingSummaryPrompt({
+ transcript: callRecording.transcript,
+ title: callRecording.title,
+ additionalSummaryPrompt: getCallRecorderAdditionalSummaryPrompt(),
+ });
+
+ if (prompt === undefined) {
+ return { outcome: 'no-transcript' };
+ }
+
+ const agentResult = await runAgent({
+ agentUniversalIdentifier:
+ CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER,
+ prompt,
+ });
+
+ const markdown = extractCallRecordingSummaryMarkdown(agentResult);
+
+ if (markdown === undefined) {
+ return { outcome: 'empty-summary' };
+ }
+
+ await updateCallRecording(client, {
+ id: callRecordingId,
+ data: { summary: { blocknote: null, markdown } },
+ });
+
+ return { outcome: 'generated' };
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts
new file mode 100644
index 0000000000..abc5a72a6f
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries-result.type.ts
@@ -0,0 +1,8 @@
+export type GenerateMissingCallRecordingSummariesResult = {
+ generatedCallRecordingIds: string[];
+ failedCallRecordingIds: string[];
+ erroredCallRecordingIds: string[];
+ skippedCallRecordingIds: string[];
+ remainingCallRecordingIds: string[];
+ continuationRequested: boolean;
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts
new file mode 100644
index 0000000000..25f810386a
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/generate-missing-call-recording-summaries.util.ts
@@ -0,0 +1,92 @@
+import { type CoreApiClient } from 'twenty-client-sdk/core';
+
+import { requestCallRecordingSummariesContinuation } from 'src/logic-functions/data/request-call-recording-summaries-continuation.util';
+import { generateCallRecordingSummary } from 'src/logic-functions/flows/generate-call-recording-summary.util';
+import { type GenerateCallRecordingSummaryResult } from 'src/logic-functions/flows/generate-call-recording-summary-result.type';
+import { type GenerateMissingCallRecordingSummariesResult } from 'src/logic-functions/flows/generate-missing-call-recording-summaries-result.type';
+
+type BatchSummaryOutcome =
+ | GenerateCallRecordingSummaryResult['outcome']
+ | 'generation-error';
+
+export const generateMissingCallRecordingSummaries = async ({
+ client,
+ callRecordingIds,
+ deadlineAtMs,
+ getNowMs = () => Date.now(),
+}: {
+ client: CoreApiClient;
+ callRecordingIds: string[];
+ deadlineAtMs: number;
+ getNowMs?: () => number;
+}): Promise => {
+ const remainingCallRecordingIds = [...callRecordingIds];
+ const generatedCallRecordingIds: string[] = [];
+ const failedCallRecordingIds: string[] = [];
+ const erroredCallRecordingIds: string[] = [];
+ const skippedCallRecordingIds: string[] = [];
+ let slowestItemMs = 0;
+
+ // Always process at least one id so the remaining list strictly shrinks β
+ // otherwise a single slow item would re-invoke with an unshrunk payload
+ // forever.
+ while (remainingCallRecordingIds.length > 0) {
+ const callRecordingId = remainingCallRecordingIds[0];
+ const itemStartedAtMs = getNowMs();
+
+ let outcome: BatchSummaryOutcome;
+
+ try {
+ ({ outcome } = await generateCallRecordingSummary(client, {
+ callRecordingId,
+ }));
+ } catch {
+ outcome = 'generation-error';
+ }
+
+ remainingCallRecordingIds.shift();
+ slowestItemMs = Math.max(slowestItemMs, getNowMs() - itemStartedAtMs);
+
+ if (outcome === 'disabled') {
+ // The workspace toggle turned off mid-run; stop spending immediately.
+ return {
+ generatedCallRecordingIds,
+ failedCallRecordingIds,
+ erroredCallRecordingIds,
+ skippedCallRecordingIds,
+ remainingCallRecordingIds,
+ continuationRequested: false,
+ };
+ }
+
+ if (outcome === 'generated') {
+ generatedCallRecordingIds.push(callRecordingId);
+ } else if (outcome === 'empty-summary') {
+ failedCallRecordingIds.push(callRecordingId);
+ } else if (outcome === 'generation-error') {
+ erroredCallRecordingIds.push(callRecordingId);
+ } else {
+ skippedCallRecordingIds.push(callRecordingId);
+ }
+
+ if (getNowMs() + slowestItemMs > deadlineAtMs) {
+ break;
+ }
+ }
+
+ const continuationRequested =
+ remainingCallRecordingIds.length > 0
+ ? await requestCallRecordingSummariesContinuation({
+ callRecordingIds: remainingCallRecordingIds,
+ })
+ : false;
+
+ return {
+ generatedCallRecordingIds,
+ failedCallRecordingIds,
+ erroredCallRecordingIds,
+ skippedCallRecordingIds,
+ remainingCallRecordingIds,
+ continuationRequested,
+ };
+};
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts
new file mode 100644
index 0000000000..b2a0762215
--- /dev/null
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/generate-call-recording-summaries.ts
@@ -0,0 +1,107 @@
+import { CoreApiClient } from 'twenty-client-sdk/core';
+import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
+
+import { GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/generate-call-recording-summaries-logic-function-universal-identifier';
+import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
+import { findCallRecordingIdsForCalendarEvents } from 'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util';
+import { findCallRecordingIdsMissingSummary } from 'src/logic-functions/data/find-call-recording-ids-missing-summary.util';
+import { generateMissingCallRecordingSummaries } from 'src/logic-functions/flows/generate-missing-call-recording-summaries.util';
+import { isCallRecordingSummaryEnabled } from 'src/logic-functions/utils/is-call-recording-summary-enabled.util';
+import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
+
+const TIMEOUT_SECONDS = 900;
+const CONTINUATION_RESERVE_MS = 30_000;
+
+type GenerateCallRecordingSummariesRouteBody = {
+ callRecordingIds?: string[];
+ calendarEventIds?: string[];
+};
+
+const hasOwnProperty = (
+ object: T | null | undefined,
+ propertyName: keyof GenerateCallRecordingSummariesRouteBody,
+): boolean =>
+ object === undefined || object === null
+ ? false
+ : Object.prototype.hasOwnProperty.call(object, propertyName);
+
+const toIdList = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter(isNonEmptyString) : [];
+
+export const generateCallRecordingSummariesHandler = async (
+ payload: RoutePayload,
+): Promise