Compare commits

...

14 Commits

Author SHA1 Message Date
Waleed
e9bdc57616 v0.5.112: trace spans improvements, fathom integration, jira fixes, canvas navigation updates 2026-03-12 13:30:20 -07:00
Waleed
e7b4da2689 feat(slack): add email field to get user and list users tools (#3509)
* feat(slack): add email field to get user and list users tools

* fix(slack): use empty string fallback for email and make type non-optional

* fix(slack): comment out users:read.email scope pending app review
2026-03-12 13:27:37 -07:00
Waleed
aa0101c666 fix(blocks): clarify condition ID suffix slicing for readability (#3546)
Use explicit hyphen separator instead of relying on slice offset to
implicitly include the hyphen in the suffix, making the intent clearer.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:26:11 -07:00
Waleed
c939f8a76e fix(jira): add explicit fields parameter to search/jql endpoint (#3544)
The GET /rest/api/3/search/jql endpoint requires an explicit `fields`
parameter to return issue data. Without it, only the issue `id` is
returned with all other fields empty. This adds `fields=*all` as the
default when the user doesn't specify custom fields.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:51:27 -07:00
Waleed
0b19ad0013 improvement(canvas): enable middle mouse button panning in cursor mode (#3542) 2026-03-12 12:44:15 -07:00
Waleed
3d5141d852 chore(oauth): remove unused github-repo generic OAuth provider (#3543) 2026-03-12 12:39:31 -07:00
Waleed
75832ca007 fix(jira): add missing write:attachment:jira oauth scope (#3541) 2026-03-12 12:13:57 -07:00
Waleed
97f78c60b4 feat(tools): add Fathom AI Notetaker integration (#3531)
* feat(fathom): add Fathom AI Notetaker integration

* fix(fathom): address PR review feedback

- Add response.ok checks to all 5 tool transformResponse functions
- Fix include_summary default to respect explicit false (check undefined)
- Add externalId validation before URL interpolation in webhook deletion

* fix(fathom): address second round PR review feedback

- Remove redundant 204 status check in deleteFathomWebhook (204 is ok)
- Use consistent undefined-guard pattern for all include flags
- Add .catch() fallback on webhook creation JSON parse
- Change recording_id default from 0 to null to avoid misleading sentinel

* fix(fathom): add missing crm_matches to list_meetings transform and fix action_items type

- Add crm_matches pass-through in list_meetings transform (was silently dropped)
- Fix action_items type to match API schema (description, user_generated, completed, etc.)
- Add crm_matches type with contacts, companies, deals, error fields

* fix(fathom): guard against undefined webhook id on creation success

* fix(fathom): add type to nested trigger outputs and fix boolean coercion

- Add type: 'object' to recorded_by and default_summary trigger outputs
- Use val === true || val === 'true' pattern for include flag coercion
  to safely handle both boolean and string values from providerConfig

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-03-12 11:00:07 -07:00
Waleed
9295499405 fix(traces): prevent condition blocks from rendering source agent's timeSegments (#3534)
* fix(traces): prevent condition blocks from rendering source agent's timeSegments

Condition blocks spread their source block's entire output into their own
output. When the source is an agent, this leaked providerTiming/timeSegments
into the condition's output, causing buildTraceSpans to create "Initial
response" as a child of the condition span instead of the agent span.

Two fixes:
- Skip timeSegment child creation for condition block types in buildTraceSpans
- Filter execution metadata (providerTiming, tokens, toolCalls, model, cost)
  from condition handler's filterSourceOutput

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(traces): guard condition blocks from leaked metadata on old persisted logs

Extend isConditionBlockType guards to also skip setting span.providerTiming,
span.cost, span.tokens, and span.model for condition blocks. This ensures
old persisted logs (recorded before the filterSourceOutput fix) don't display
misleading execution metadata on condition spans.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(traces): guard toolCalls fallback path for condition blocks on old logs

The else branch that extracts toolCalls from log.output also needs a
condition block guard, otherwise old persisted logs with leaked toolCalls
from the source agent would render on the condition span.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(traces): extract isCondition to local variable for readability

Cache isConditionBlockType(log.blockType) in a local const at the top
of the forEach loop instead of calling it 6 times per iteration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:39:02 -07:00
Waleed
6bcbd15ee6 fix(blocks): remap condition/router IDs when duplicating blocks (#3533)
* fix(blocks): remap condition/router IDs when duplicating blocks

Condition and router blocks embed IDs in the format `{blockId}-{suffix}`
inside their subBlock values and edge sourceHandles. When blocks were
duplicated, these IDs were not updated to reference the new block ID,
causing duplicate handle IDs and broken edge routing.

Fixes all four duplication paths: single block duplicate, copy/paste,
workflow duplication (server-side), and workflow import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(blocks): deep-clone subBlocks before mutating condition IDs

Shallow copy of subBlocks meant remapConditionIds could mutate the
source data (clipboard on repeated paste, or input workflowState on
import). Deep-clone subBlocks in both regenerateBlockIds and
regenerateWorkflowIds to prevent this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(blocks): remap condition IDs in regenerateWorkflowStateIds (template use)

The template use code path was missing condition/router ID remapping,
causing broken condition blocks when creating workflows from templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:19:38 -07:00
Vikhyath Mondreti
36612ae42a v0.5.111: non-polling webhook execs off trigger.dev, gmail subject headers, webhook trigger configs (#3530) 2026-03-11 17:47:28 -07:00
Vikhyath Mondreti
68d207df94 improvement(webhooks): move non-polling executions off trigger.dev (#3527)
* improvement(webhooks): move non-polling off trigger.dev

* restore constants file

* improve comment

* add unit test to prevent drift
2026-03-11 17:07:24 -07:00
Vikhyath Mondreti
d5502d602b feat(webhooks): dedup and custom ack configuration (#3525)
* feat(webhooks): dedup and custom ack configuration

* address review comments

* reject object typed idempotency key
2026-03-11 15:51:35 -07:00
Waleed
37d524bb0a fix(gmail): RFC 2047 encode subject headers for non-ASCII characters (#3526)
* fix(gmail): RFC 2047 encode subject headers for non-ASCII characters

* Fix RFC 2047 encoded word length limit

Split long email subjects into multiple RFC 2047 encoded words to comply with the 75-character limit per RFC 2047 Section 2. Each encoded word now contains at most 45 bytes of UTF-8 content (producing max 60 chars of base64 + 12 chars overhead = 72 total). Multiple encoded words are separated by CRLF + space (folding whitespace).

Applied via @cursor push command

* fix(gmail): split RFC 2047 encoded words on character boundaries

* fix(gmail): simplify RFC 2047 encoding to match Google's own sample

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-11 15:48:07 -07:00
61 changed files with 2159 additions and 236 deletions

View File

@@ -1979,6 +1979,24 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
<path
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
fill='#007299'
/>
<path
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
<path
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
</svg>
)
}
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>

View File

@@ -43,6 +43,7 @@ import {
EvernoteIcon,
ExaAIIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
FirefliesIcon,
GammaIcon,
@@ -206,6 +207,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies_v2: FirefliesIcon,

View File

@@ -0,0 +1,135 @@
---
title: Fathom
description: Access meeting recordings, transcripts, and summaries
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="fathom"
color="#181C1E"
/>
## Usage Instructions
Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.
## Tools
### `fathom_list_meetings`
List recent meetings recorded by the user or shared to their team.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `includeSummary` | string | No | Include meeting summary \(true/false\) |
| `includeTranscript` | string | No | Include meeting transcript \(true/false\) |
| `includeActionItems` | string | No | Include action items \(true/false\) |
| `includeCrmMatches` | string | No | Include linked CRM matches \(true/false\) |
| `createdAfter` | string | No | Filter meetings created after this ISO 8601 timestamp |
| `createdBefore` | string | No | Filter meetings created before this ISO 8601 timestamp |
| `recordedBy` | string | No | Filter by recorder email address |
| `teams` | string | No | Filter by team name |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `meetings` | array | List of meetings |
| ↳ `title` | string | Meeting title |
| ↳ `recording_id` | number | Unique recording ID |
| ↳ `url` | string | URL to view the meeting |
| ↳ `share_url` | string | Shareable URL |
| ↳ `created_at` | string | Creation timestamp |
| ↳ `transcript_language` | string | Transcript language |
| `next_cursor` | string | Pagination cursor for next page |
### `fathom_get_summary`
Get the call summary for a specific meeting recording.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `recordingId` | string | Yes | The recording ID of the meeting |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `template_name` | string | Name of the summary template used |
| `markdown_formatted` | string | Markdown-formatted summary text |
### `fathom_get_transcript`
Get the full transcript for a specific meeting recording.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `recordingId` | string | Yes | The recording ID of the meeting |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `transcript` | array | Array of transcript entries with speaker, text, and timestamp |
| ↳ `speaker` | object | Speaker information |
| ↳ `display_name` | string | Speaker display name |
| ↳ `matched_calendar_invitee_email` | string | Matched calendar invitee email |
| ↳ `text` | string | Transcript text |
| ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) |
### `fathom_list_team_members`
List team members in your Fathom organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `teams` | string | No | Team name to filter by |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `members` | array | List of team members |
| ↳ `name` | string | Team member name |
| ↳ `email` | string | Team member email |
| ↳ `created_at` | string | Date the member was added |
| `next_cursor` | string | Pagination cursor for next page |
### `fathom_list_teams`
List teams in your Fathom organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `teams` | array | List of teams |
| ↳ `name` | string | Team name |
| ↳ `created_at` | string | Date the team was created |
| `next_cursor` | string | Pagination cursor for next page |

View File

@@ -37,6 +37,7 @@
"enrich",
"evernote",
"exa",
"fathom",
"file",
"firecrawl",
"fireflies",

View File

@@ -590,6 +590,7 @@ List all users in a Slack workspace. Returns user profiles with names and avatar
| ↳ `name` | string | Username \(handle\) |
| ↳ `real_name` | string | Full real name |
| ↳ `display_name` | string | Display name shown in Slack |
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
| ↳ `is_bot` | boolean | Whether the user is a bot |
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |
@@ -629,6 +630,7 @@ Get detailed information about a specific Slack user by their user ID.
| ↳ `title` | string | Job title |
| ↳ `phone` | string | Phone number |
| ↳ `skype` | string | Skype handle |
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
| ↳ `is_bot` | boolean | Whether the user is a bot |
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |

View File

@@ -367,9 +367,7 @@ export async function POST(request: NextRequest) {
)
}
// Configure each new webhook (for providers that need configuration)
const pollingProviders = ['gmail', 'outlook']
const needsConfiguration = pollingProviders.includes(provider)
const needsConfiguration = provider === 'gmail' || provider === 'outlook'
if (needsConfiguration) {
const configureFunc =

View File

@@ -12,7 +12,7 @@ interface UseShiftSelectionLockResult {
/** Computed ReactFlow props based on current selection state */
selectionProps: {
selectionOnDrag: boolean
panOnDrag: [number, number] | false
panOnDrag: number[]
selectionKeyCode: string | null
}
}
@@ -55,7 +55,7 @@ export function useShiftSelectionLock({
const selectionProps = {
selectionOnDrag: !isHandMode || isShiftSelecting,
panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false,
panOnDrag: isHandMode && !isShiftSelecting ? [0, 1] : [1],
selectionKeyCode: isShiftSelecting ? null : 'Shift',
}

View File

@@ -0,0 +1,211 @@
import { FathomIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { FathomResponse } from '@/tools/fathom/types'
import { getTrigger } from '@/triggers'
import { fathomTriggerOptions } from '@/triggers/fathom/utils'
export const FathomBlock: BlockConfig<FathomResponse> = {
type: 'fathom',
name: 'Fathom',
description: 'Access meeting recordings, transcripts, and summaries',
authMode: AuthMode.ApiKey,
triggerAllowed: true,
longDescription:
'Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.',
docsLink: 'https://docs.sim.ai/tools/fathom',
category: 'tools',
bgColor: '#181C1E',
icon: FathomIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'List Meetings', id: 'fathom_list_meetings' },
{ label: 'Get Summary', id: 'fathom_get_summary' },
{ label: 'Get Transcript', id: 'fathom_get_transcript' },
{ label: 'List Team Members', id: 'fathom_list_team_members' },
{ label: 'List Teams', id: 'fathom_list_teams' },
],
value: () => 'fathom_list_meetings',
},
{
id: 'recordingId',
title: 'Recording ID',
type: 'short-input',
required: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
placeholder: 'Enter the recording ID',
condition: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
},
{
id: 'includeSummary',
title: 'Include Summary',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeTranscript',
title: 'Include Transcript',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeActionItems',
title: 'Include Action Items',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeCrmMatches',
title: 'Include CRM Matches',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'createdAfter',
title: 'Created After',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'createdBefore',
title: 'Created Before',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g., 2025-12-31T23:59:59Z)',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'recordedBy',
title: 'Recorded By',
type: 'short-input',
placeholder: 'Filter by recorder email',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
},
{
id: 'teams',
title: 'Team',
type: 'short-input',
placeholder: 'Filter by team name',
condition: {
field: 'operation',
value: ['fathom_list_meetings', 'fathom_list_team_members'],
},
mode: 'advanced',
},
{
id: 'cursor',
title: 'Pagination Cursor',
type: 'short-input',
placeholder: 'Cursor from a previous response',
condition: {
field: 'operation',
value: ['fathom_list_meetings', 'fathom_list_team_members', 'fathom_list_teams'],
},
mode: 'advanced',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
required: true,
placeholder: 'Enter your Fathom API key',
password: true,
},
{
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
options: fathomTriggerOptions,
value: () => 'fathom_new_meeting',
required: true,
},
...getTrigger('fathom_new_meeting').subBlocks,
...getTrigger('fathom_webhook').subBlocks,
],
tools: {
access: [
'fathom_list_meetings',
'fathom_get_summary',
'fathom_get_transcript',
'fathom_list_team_members',
'fathom_list_teams',
],
config: {
tool: (params) => {
return params.operation || 'fathom_list_meetings'
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Fathom API key' },
recordingId: { type: 'string', description: 'Recording ID for summary or transcript' },
includeSummary: { type: 'string', description: 'Include summary in meetings response' },
includeTranscript: { type: 'string', description: 'Include transcript in meetings response' },
includeActionItems: {
type: 'string',
description: 'Include action items in meetings response',
},
includeCrmMatches: {
type: 'string',
description: 'Include linked CRM matches in meetings response',
},
createdAfter: { type: 'string', description: 'Filter meetings created after this timestamp' },
createdBefore: {
type: 'string',
description: 'Filter meetings created before this timestamp',
},
recordedBy: { type: 'string', description: 'Filter by recorder email' },
teams: { type: 'string', description: 'Filter by team name' },
cursor: { type: 'string', description: 'Pagination cursor for next page' },
},
outputs: {
meetings: { type: 'json', description: 'List of meetings' },
template_name: { type: 'string', description: 'Summary template name' },
markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' },
transcript: { type: 'json', description: 'Meeting transcript entries' },
members: { type: 'json', description: 'List of team members' },
teams: { type: 'json', description: 'List of teams' },
next_cursor: { type: 'string', description: 'Pagination cursor' },
},
triggers: {
enabled: true,
available: ['fathom_new_meeting', 'fathom_webhook'],
},
}

View File

@@ -18,6 +18,7 @@ export const GenericWebhookBlock: BlockConfig = {
bestPractices: `
- You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}'
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
- To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
`,
subBlocks: [...getTrigger('generic_webhook').subBlocks],

View File

@@ -40,6 +40,7 @@ import { EnrichBlock } from '@/blocks/blocks/enrich'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { EvernoteBlock } from '@/blocks/blocks/evernote'
import { ExaBlock } from '@/blocks/blocks/exa'
import { FathomBlock } from '@/blocks/blocks/fathom'
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies'
@@ -235,6 +236,7 @@ export const registry: Record<string, BlockConfig> = {
dynamodb: DynamoDBBlock,
elasticsearch: ElasticsearchBlock,
elevenlabs: ElevenLabsBlock,
fathom: FathomBlock,
enrich: EnrichBlock,
evernote: EvernoteBlock,
evaluator: EvaluatorBlock,

View File

@@ -1979,6 +1979,24 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
<path
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
fill='#007299'
/>
<path
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
<path
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
</svg>
)
}
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>

View File

@@ -166,7 +166,8 @@ export class ConditionBlockHandler implements BlockHandler {
if (!output || typeof output !== 'object') {
return output
}
const { _pauseMetadata, error, ...rest } = output
const { _pauseMetadata, error, providerTiming, tokens, toolCalls, model, cost, ...rest } =
output
return rest
}

View File

@@ -22,7 +22,7 @@ export class TriggerBlockHandler implements BlockHandler {
}
const existingState = ctx.blockStates.get(block.id)
if (existingState?.output && Object.keys(existingState.output).length > 0) {
if (existingState?.output) {
return existingState.output
}

View File

@@ -492,7 +492,7 @@ export const auth = betterAuth({
'google-meet',
'google-tasks',
'vertex-ai',
'github-repo',
'microsoft-dataverse',
'microsoft-teams',
'microsoft-excel',
@@ -754,83 +754,6 @@ export const auth = betterAuth({
}),
genericOAuth({
config: [
{
providerId: 'github-repo',
clientId: env.GITHUB_REPO_CLIENT_ID as string,
clientSecret: env.GITHUB_REPO_CLIENT_SECRET as string,
authorizationUrl: 'https://github.com/login/oauth/authorize',
accessType: 'offline',
prompt: 'consent',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: getCanonicalScopesForProvider('github-repo'),
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/github-repo`,
getUserInfo: async (tokens) => {
try {
const profileResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'User-Agent': 'sim-studio',
},
})
if (!profileResponse.ok) {
await profileResponse.text().catch(() => {})
logger.error('Failed to fetch GitHub profile', {
status: profileResponse.status,
statusText: profileResponse.statusText,
})
throw new Error(`Failed to fetch GitHub profile: ${profileResponse.statusText}`)
}
const profile = await profileResponse.json()
if (!profile.email) {
const emailsResponse = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'User-Agent': 'sim-studio',
},
})
if (emailsResponse.ok) {
const emails = await emailsResponse.json()
const primaryEmail =
emails.find(
(email: { primary: boolean; email: string; verified: boolean }) =>
email.primary
) || emails[0]
if (primaryEmail) {
profile.email = primaryEmail.email
profile.emailVerified = primaryEmail.verified || false
}
} else {
logger.warn('Failed to fetch GitHub emails', {
status: emailsResponse.status,
statusText: emailsResponse.statusText,
})
}
}
const now = new Date()
return {
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
name: profile.name || profile.login,
email: profile.email,
image: profile.avatar_url,
emailVerified: profile.emailVerified || false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in GitHub getUserInfo', { error })
throw error
}
},
},
// Google providers
{
providerId: 'google-email',

View File

@@ -7,6 +7,7 @@ const logger = createLogger('AsyncJobsConfig')
let cachedBackend: JobQueueBackend | null = null
let cachedBackendType: AsyncBackendType | null = null
let cachedInlineBackend: JobQueueBackend | null = null
/**
* Determines which async backend to use based on environment configuration.
@@ -71,6 +72,31 @@ export function getCurrentBackendType(): AsyncBackendType | null {
return cachedBackendType
}
/**
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
* Used for non-polling webhooks that should always execute inline.
*/
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
if (cachedInlineBackend) {
return cachedInlineBackend
}
const redis = getRedisClient()
let type: string
if (redis) {
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
cachedInlineBackend = new RedisJobQueue(redis)
type = 'redis'
} else {
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
cachedInlineBackend = new DatabaseJobQueue()
type = 'database'
}
logger.info(`Inline job backend initialized: ${type}`)
return cachedInlineBackend
}
/**
* Checks if jobs should be executed inline (fire-and-forget).
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
@@ -85,4 +111,5 @@ export function shouldExecuteInline(): boolean {
export function resetJobQueueCache(): void {
cachedBackend = null
cachedBackendType = null
cachedInlineBackend = null
}

View File

@@ -1,6 +1,7 @@
export {
getAsyncBackendType,
getCurrentBackendType,
getInlineJobQueue,
getJobQueue,
resetJobQueueCache,
shouldExecuteInline,

View File

@@ -230,8 +230,7 @@ export const env = createEnv({
GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret
GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
GITHUB_REPO_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for repo access
GITHUB_REPO_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret for repo access
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID

View File

@@ -413,6 +413,7 @@ export class IdempotencyService {
: undefined
const webhookIdHeader =
normalizedHeaders?.['x-sim-idempotency-key'] ||
normalizedHeaders?.['webhook-id'] ||
normalizedHeaders?.['x-webhook-id'] ||
normalizedHeaders?.['x-shopify-webhook-id'] ||

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import type { ToolCall, TraceSpan } from '@/lib/logs/types'
import { isWorkflowBlockType, stripCustomToolPrefix } from '@/executor/constants'
import {
isConditionBlockType,
isWorkflowBlockType,
stripCustomToolPrefix,
} from '@/executor/constants'
import type { ExecutionResult } from '@/executor/types'
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
@@ -109,6 +113,7 @@ export function buildTraceSpans(result: ExecutionResult): {
if (!log.blockId || !log.blockType) return
const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}`
const isCondition = isConditionBlockType(log.blockType)
const duration = log.durationMs || 0
@@ -164,7 +169,7 @@ export function buildTraceSpans(result: ExecutionResult): {
...(log.parentIterations?.length && { parentIterations: log.parentIterations }),
}
if (log.output?.providerTiming) {
if (!isCondition && log.output?.providerTiming) {
const providerTiming = log.output.providerTiming as {
duration: number
startTime: string
@@ -186,7 +191,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (log.output?.cost) {
if (!isCondition && log.output?.cost) {
span.cost = log.output.cost as {
input?: number
output?: number
@@ -194,7 +199,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (log.output?.tokens) {
if (!isCondition && log.output?.tokens) {
const t = log.output.tokens as
| number
| {
@@ -224,12 +229,13 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (log.output?.model) {
if (!isCondition && log.output?.model) {
span.model = log.output.model as string
}
if (
!isWorkflowBlockType(log.blockType) &&
!isCondition &&
log.output?.providerTiming?.timeSegments &&
Array.isArray(log.output.providerTiming.timeSegments)
) {
@@ -317,7 +323,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
)
} else {
} else if (!isCondition) {
let toolCallsList = null
try {

View File

@@ -170,11 +170,6 @@ describe('OAuth Token Refresh', () => {
describe('Body Credential Providers', () => {
const bodyCredentialProviders = [
{ name: 'Google', providerId: 'google', endpoint: 'https://oauth2.googleapis.com/token' },
{
name: 'GitHub',
providerId: 'github',
endpoint: 'https://github.com/login/oauth/access_token',
},
{
name: 'Microsoft',
providerId: 'microsoft',
@@ -279,19 +274,6 @@ describe('OAuth Token Refresh', () => {
)
})
it.concurrent('should include Accept header for GitHub requests', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
await withMockFetch(mockFetch, () => refreshOAuthToken('github', refreshToken))
const [, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
expect(requestOptions.headers.Accept).toBe('application/json')
})
it.concurrent('should include User-Agent header for Reddit requests', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'

View File

@@ -6,7 +6,6 @@ import {
CalComIcon,
ConfluenceIcon,
DropboxIcon,
GithubIcon,
GmailIcon,
GoogleBigQueryIcon,
GoogleCalendarIcon,
@@ -340,21 +339,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'outlook',
},
github: {
name: 'GitHub',
icon: GithubIcon,
services: {
github: {
name: 'GitHub',
description: 'Manage repositories, issues, and pull requests.',
providerId: 'github-repo',
icon: GithubIcon,
baseProviderIcon: GithubIcon,
scopes: ['repo', 'user:email', 'read:user', 'workflow'],
},
},
defaultService: 'github',
},
x: {
name: 'X',
icon: xIcon,
@@ -474,6 +458,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'read:comment:jira',
'delete:comment:jira',
'read:attachment:jira',
'write:attachment:jira',
'delete:attachment:jira',
'write:issue-worklog:jira',
'read:issue-worklog:jira',
@@ -639,6 +624,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'im:history',
'im:read',
'users:read',
// TODO: Add 'users:read.email' once Slack app review is approved
'files:write',
'files:read',
'canvases:write',
@@ -987,19 +973,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
useBasicAuth: false,
}
}
case 'github': {
const { clientId, clientSecret } = getCredentials(
env.GITHUB_CLIENT_ID,
env.GITHUB_CLIENT_SECRET
)
return {
tokenEndpoint: 'https://github.com/login/oauth/access_token',
clientId,
clientSecret,
useBasicAuth: false,
additionalHeaders: { Accept: 'application/json' },
}
}
case 'x': {
const { clientId, clientSecret } = getCredentials(env.X_CLIENT_ID, env.X_CLIENT_SECRET)
return {

View File

@@ -15,8 +15,6 @@ export type OAuthProvider =
| 'google-groups'
| 'google-meet'
| 'vertex-ai'
| 'github'
| 'github-repo'
| 'x'
| 'confluence'
| 'airtable'
@@ -64,7 +62,6 @@ export type OAuthService =
| 'google-groups'
| 'google-meet'
| 'vertex-ai'
| 'github'
| 'x'
| 'confluence'
| 'airtable'

View File

@@ -66,11 +66,6 @@ describe('getAllOAuthServices', () => {
it.concurrent('should include single-service providers', () => {
const services = getAllOAuthServices()
const githubService = services.find((s) => s.providerId === 'github-repo')
expect(githubService).toBeDefined()
expect(githubService?.name).toBe('GitHub')
expect(githubService?.baseProvider).toBe('github')
const slackService = services.find((s) => s.providerId === 'slack')
expect(slackService).toBeDefined()
expect(slackService?.name).toBe('Slack')
@@ -145,14 +140,6 @@ describe('getServiceByProviderAndId', () => {
expect(service.name).toBe('Microsoft Excel')
})
it.concurrent('should work with single-service providers', () => {
const service = getServiceByProviderAndId('github')
expect(service).toBeDefined()
expect(service.providerId).toBe('github-repo')
expect(service.name).toBe('GitHub')
})
it.concurrent('should include scopes in returned service config', () => {
const service = getServiceByProviderAndId('google', 'gmail')
@@ -182,12 +169,6 @@ describe('getProviderIdFromServiceId', () => {
expect(providerId).toBe('outlook')
})
it.concurrent('should return correct providerId for GitHub', () => {
const providerId = getProviderIdFromServiceId('github')
expect(providerId).toBe('github-repo')
})
it.concurrent('should return correct providerId for Microsoft Excel', () => {
const providerId = getProviderIdFromServiceId('microsoft-excel')
@@ -262,14 +243,6 @@ describe('getServiceConfigByProviderId', () => {
expect(excelService?.name).toBe('Microsoft Excel')
})
it.concurrent('should work for GitHub', () => {
const service = getServiceConfigByProviderId('github-repo')
expect(service).toBeDefined()
expect(service?.providerId).toBe('github-repo')
expect(service?.name).toBe('GitHub')
})
it.concurrent('should work for Slack', () => {
const service = getServiceConfigByProviderId('slack')
@@ -338,14 +311,6 @@ describe('getCanonicalScopesForProvider', () => {
expect(excelScopes).toContain('Files.Read')
})
it.concurrent('should return scopes for GitHub', () => {
const scopes = getCanonicalScopesForProvider('github-repo')
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('repo')
expect(scopes).toContain('user:email')
})
it.concurrent('should handle providers with empty scopes array', () => {
const scopes = getCanonicalScopesForProvider('notion')
@@ -397,13 +362,6 @@ describe('parseProvider', () => {
expect(teamsConfig.featureType).toBe('microsoft-teams')
})
it.concurrent('should parse GitHub provider', () => {
const config = parseProvider('github-repo' as OAuthProvider)
expect(config.baseProvider).toBe('github')
expect(config.featureType).toBe('github')
})
it.concurrent('should parse Slack provider', () => {
const config = parseProvider('slack' as OAuthProvider)

View File

@@ -157,6 +157,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
'read:comment:jira': 'Read comments on Jira issues',
'delete:comment:jira': 'Delete comments from Jira issues',
'read:attachment:jira': 'Read attachments from Jira issues',
'write:attachment:jira': 'Add attachments to Jira issues',
'delete:attachment:jira': 'Delete attachments from Jira issues',
'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues',
'read:issue-worklog:jira': 'Read worklog entries from Jira issues',
@@ -269,6 +270,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
'im:history': 'Read direct message history',
'im:read': 'View direct message channels',
'users:read': 'View workspace users',
'users:read.email': 'View user email addresses',
'files:write': 'Upload files',
'files:read': 'Download and read files',
'canvases:write': 'Create canvas documents',

View File

@@ -5,7 +5,7 @@ import { and, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { isProd } from '@/lib/core/config/feature-flags'
import { safeCompare } from '@/lib/core/security/encryption'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
@@ -29,6 +29,7 @@ import {
import { executeWebhookJob } from '@/background/webhook-execution'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { isConfluencePayloadMatch } from '@/triggers/confluence/utils'
import { isPollingWebhookProvider } from '@/triggers/constants'
import { isGitHubEventMatch } from '@/triggers/github/utils'
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
import { isJiraEventMatch } from '@/triggers/jira/utils'
@@ -1049,7 +1050,7 @@ export async function queueWebhookExecution(
}
}
const headers = Object.fromEntries(request.headers.entries())
const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries())
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
if (
@@ -1067,9 +1068,20 @@ export async function queueWebhookExecution(
}
}
// Extract credentialId from webhook config
// Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
if (foundWebhook.provider === 'generic') {
const idempotencyField = providerConfig.idempotencyField as string | undefined
if (idempotencyField && body) {
const value = idempotencyField
.split('.')
.reduce((acc: any, key: string) => acc?.[key], body)
if (value !== undefined && value !== null && typeof value !== 'object') {
headers['x-sim-idempotency-key'] = String(value)
}
}
}
const credentialId = providerConfig.credentialId as string | undefined
// credentialSetId is a direct field on webhook table, not in providerConfig
@@ -1105,15 +1117,24 @@ export async function queueWebhookExecution(
...(credentialId ? { credentialId } : {}),
}
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
})
logger.info(
`[${options.requestId}] Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook`
)
const isPolling = isPollingWebhookProvider(payload.provider)
if (shouldExecuteInline()) {
if (isPolling && !shouldExecuteInline()) {
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
})
logger.info(
`[${options.requestId}] Queued polling webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue`
)
} else {
const jobQueue = await getInlineJobQueue()
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
})
logger.info(
`[${options.requestId}] Executing ${foundWebhook.provider} webhook ${jobId} inline`
)
void (async () => {
try {
await jobQueue.startJob(jobId)
@@ -1193,6 +1214,26 @@ export async function queueWebhookExecution(
})
}
if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') {
const rawCode = Number(providerConfig.responseStatusCode) || 200
const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200
const responseBody = (providerConfig.responseBody as string | undefined)?.trim()
if (!responseBody) {
return new NextResponse(null, { status: statusCode })
}
try {
const parsed = JSON.parse(responseBody)
return NextResponse.json(parsed, { status: statusCode })
} catch {
return new NextResponse(responseBody, {
status: statusCode,
headers: { 'Content-Type': 'text/plain' },
})
}
}
return NextResponse.json({ message: 'Webhook processed' })
} catch (error: any) {
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)

View File

@@ -17,6 +17,7 @@ const airtableLogger = createLogger('AirtableWebhook')
const typeformLogger = createLogger('TypeformWebhook')
const calendlyLogger = createLogger('CalendlyWebhook')
const grainLogger = createLogger('GrainWebhook')
const fathomLogger = createLogger('FathomWebhook')
const lemlistLogger = createLogger('LemlistWebhook')
const webflowLogger = createLogger('WebflowWebhook')
const attioLogger = createLogger('AttioWebhook')
@@ -792,6 +793,60 @@ export async function deleteGrainWebhook(webhook: any, requestId: string): Promi
}
}
/**
* Delete a Fathom webhook
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteFathomWebhook(webhook: any, requestId: string): Promise<void> {
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
fathomLogger.warn(
`[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
if (!externalId) {
fathomLogger.warn(
`[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100)
if (!idValidation.isValid) {
fathomLogger.warn(
`[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}`
const fathomResponse = await fetch(fathomApiUrl, {
method: 'DELETE',
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
},
})
if (!fathomResponse.ok && fathomResponse.status !== 404) {
fathomLogger.warn(
`[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}`
)
} else {
fathomLogger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`)
}
} catch (error) {
fathomLogger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error)
}
}
/**
* Delete a Lemlist webhook
* Don't fail webhook deletion if cleanup fails
@@ -1314,6 +1369,116 @@ export async function createGrainWebhookSubscription(
}
}
export async function createFathomWebhookSubscription(
_request: NextRequest,
webhookData: any,
requestId: string
): Promise<{ id: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const {
apiKey,
triggerId,
triggeredFor,
includeSummary,
includeTranscript,
includeActionItems,
includeCrmMatches,
} = providerConfig || {}
if (!apiKey) {
fathomLogger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Fathom API Key is required. Please provide your API key in the trigger configuration.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const triggeredForValue = triggeredFor || 'my_recordings'
const toBool = (val: unknown, fallback: boolean): boolean => {
if (val === undefined) return fallback
return val === true || val === 'true'
}
const requestBody: Record<string, any> = {
destination_url: notificationUrl,
triggered_for: [triggeredForValue],
include_summary: toBool(includeSummary, true),
include_transcript: toBool(includeTranscript, false),
include_action_items: toBool(includeActionItems, false),
include_crm_matches: toBool(includeCrmMatches, false),
}
fathomLogger.info(`[${requestId}] Creating Fathom webhook`, {
triggerId,
triggeredFor: triggeredForValue,
webhookId: webhookData.id,
})
const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', {
method: 'POST',
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await fathomResponse.json().catch(() => ({}))
if (!fathomResponse.ok) {
const errorMessage =
(responseBody as Record<string, string>).message ||
(responseBody as Record<string, string>).error ||
'Unknown Fathom API error'
fathomLogger.error(
`[${requestId}] Failed to create webhook in Fathom for webhook ${webhookData.id}. Status: ${fathomResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Fathom'
if (fathomResponse.status === 401) {
userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.'
} else if (fathomResponse.status === 400) {
userFriendlyMessage = `Fathom error: ${errorMessage}`
} else if (errorMessage && errorMessage !== 'Unknown Fathom API error') {
userFriendlyMessage = `Fathom error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
if (!responseBody.id) {
fathomLogger.error(
`[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhookData.id}.`
)
throw new Error('Fathom webhook created but no ID returned. Please try again.')
}
fathomLogger.info(
`[${requestId}] Successfully created webhook in Fathom for webhook ${webhookData.id}.`,
{
fathomWebhookId: responseBody.id,
}
)
return { id: responseBody.id }
} catch (error: any) {
fathomLogger.error(
`[${requestId}] Exception during Fathom webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}
export async function createLemlistWebhookSubscription(
webhookData: any,
requestId: string
@@ -1811,6 +1976,7 @@ const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([
'airtable',
'attio',
'calendly',
'fathom',
'webflow',
'typeform',
'grain',
@@ -1923,6 +2089,12 @@ export async function createExternalWebhookSubscription(
updatedProviderConfig = { ...updatedProviderConfig, webhookTag: usedTag }
}
externalSubscriptionCreated = true
} else if (provider === 'fathom') {
const result = await createFathomWebhookSubscription(request, webhookData, requestId)
if (result) {
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
externalSubscriptionCreated = true
}
} else if (provider === 'grain') {
const result = await createGrainWebhookSubscription(request, webhookData, requestId)
if (result) {
@@ -1968,6 +2140,8 @@ export async function cleanupExternalWebhook(
await deleteCalendlyWebhook(webhook, requestId)
} else if (webhook.provider === 'webflow') {
await deleteWebflowWebhook(webhook, workflow, requestId)
} else if (webhook.provider === 'fathom') {
await deleteFathomWebhook(webhook, requestId)
} else if (webhook.provider === 'grain') {
await deleteGrainWebhook(webhook, requestId)
} else if (webhook.provider === 'lemlist') {

View File

@@ -19,6 +19,7 @@ import {
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { isPollingWebhookProvider } from '@/triggers/constants'
const logger = createLogger('WebhookUtils')
@@ -2222,10 +2223,7 @@ export async function syncWebhooksForCredentialSet(params: {
`[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}`
)
// Polling providers get unique paths per credential (for independent state)
// External webhook providers share the same path (external service sends to one URL)
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
const useUniquePaths = pollingProviders.includes(provider)
const useUniquePaths = isPollingWebhookProvider(provider)
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)

View File

@@ -433,7 +433,7 @@ describe('hasWorkflowChanged', () => {
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should detect subBlock type changes', () => {
it.concurrent('should ignore subBlock type changes', () => {
const state1 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
@@ -448,7 +448,7 @@ describe('hasWorkflowChanged', () => {
}),
},
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
it.concurrent('should handle null/undefined subBlock values consistently', () => {

View File

@@ -496,7 +496,14 @@ export function normalizeSubBlockValue(subBlockId: string, value: unknown): unkn
* @returns SubBlock fields excluding value and is_diff
*/
export function extractSubBlockRest(subBlock: Record<string, unknown>): Record<string, unknown> {
const { value: _v, is_diff: _sd, ...rest } = subBlock as SubBlockWithDiffMarker
const {
value: _v,
is_diff: _sd,
type: _type,
...rest
} = subBlock as SubBlockWithDiffMarker & {
type?: unknown
}
return rest
}

View File

@@ -0,0 +1,57 @@
import { EDGE } from '@/executor/constants'
/**
* Remaps condition/router block IDs in a parsed conditions array.
* Condition IDs use the format `{blockId}-{suffix}` and must be updated
* when a block is duplicated to reference the new block ID.
*
* @param conditions - Parsed array of condition block objects with `id` fields
* @param oldBlockId - The original block ID prefix to replace
* @param newBlockId - The new block ID prefix
* @returns Whether any IDs were changed (mutates in place)
*/
export function remapConditionBlockIds(
conditions: Array<{ id: string; [key: string]: unknown }>,
oldBlockId: string,
newBlockId: string
): boolean {
let changed = false
const prefix = `${oldBlockId}-`
for (const condition of conditions) {
if (typeof condition.id === 'string' && condition.id.startsWith(prefix)) {
const suffix = condition.id.slice(prefix.length)
condition.id = `${newBlockId}-${suffix}`
changed = true
}
}
return changed
}
/** Handle prefixes that embed block-scoped condition/route IDs */
const HANDLE_PREFIXES = [EDGE.CONDITION_PREFIX, EDGE.ROUTER_PREFIX] as const
/**
* Remaps a condition or router edge sourceHandle from the old block ID to the new one.
* Handle formats:
* - Condition: `condition-{blockId}-{suffix}`
* - Router V2: `router-{blockId}-{suffix}`
*
* @returns The remapped handle string, or the original if no remapping needed
*/
export function remapConditionEdgeHandle(
sourceHandle: string,
oldBlockId: string,
newBlockId: string
): string {
for (const handlePrefix of HANDLE_PREFIXES) {
if (!sourceHandle.startsWith(handlePrefix)) continue
const innerId = sourceHandle.slice(handlePrefix.length)
if (!innerId.startsWith(`${oldBlockId}-`)) continue
const suffix = innerId.slice(oldBlockId.length + 1)
return `${handlePrefix}${newBlockId}-${suffix}`
}
return sourceHandle
}

View File

@@ -8,6 +8,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, min } from 'drizzle-orm'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { Variable } from '@/stores/panel/variables/types'
@@ -77,6 +78,40 @@ function remapVariableIdsInSubBlocks(
return updated
}
/**
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
* Returns a new object without mutating the input.
*/
function remapConditionIdsInSubBlocks(
subBlocks: Record<string, any>,
oldBlockId: string,
newBlockId: string
): Record<string, any> {
const updated: Record<string, any> = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (
subBlock &&
typeof subBlock === 'object' &&
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
typeof subBlock.value === 'string'
) {
try {
const parsed = JSON.parse(subBlock.value)
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
continue
}
} catch {
// Not valid JSON, skip
}
}
updated[key] = subBlock
}
return updated
}
/**
* Duplicate a workflow with all its blocks, edges, and subflows
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
@@ -259,6 +294,15 @@ export async function duplicateWorkflow(
)
}
// Remap condition/router IDs to use the new block ID
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
updatedSubBlocks = remapConditionIdsInSubBlocks(
updatedSubBlocks as Record<string, any>,
block.id,
newBlockId
)
}
return {
...block,
id: newBlockId,
@@ -286,15 +330,24 @@ export async function duplicateWorkflow(
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
if (sourceEdges.length > 0) {
const newEdges = sourceEdges.map((edge) => ({
...edge,
id: crypto.randomUUID(), // Generate new edge ID
workflowId: newWorkflowId,
sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId,
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
createdAt: now,
updatedAt: now,
}))
const newEdges = sourceEdges.map((edge) => {
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId
const newSourceHandle =
edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId)
? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId)
: edge.sourceHandle
return {
...edge,
id: crypto.randomUUID(),
workflowId: newWorkflowId,
sourceBlockId: newSourceBlockId,
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
sourceHandle: newSourceHandle,
createdAt: now,
updatedAt: now,
}
})
await tx.insert(workflowEdges).values(newEdges)
logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`)

View File

@@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import type { DbOrTx } from '@/lib/db/types'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import {
backfillCanonicalModes,
migrateSubblockIds,
@@ -833,7 +834,12 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
const newId = blockIdMapping.get(oldId)!
// Duplicated blocks are always unlocked so users can edit them
const newBlock: BlockState = { ...block, id: newId, locked: false }
const newBlock: BlockState = {
...block,
id: newId,
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
locked: false,
}
// Update parentId reference if it exists
if (newBlock.data?.parentId) {
@@ -857,6 +863,21 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value
}
// Remap condition/router IDs embedded in condition-input/router-input subBlocks
if (
(updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') &&
typeof updatedSubBlock.value === 'string'
) {
try {
const parsed = JSON.parse(updatedSubBlock.value)
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) {
updatedSubBlock.value = JSON.stringify(parsed)
}
} catch {
// Not valid JSON, skip
}
}
updatedSubBlocks[subId] = updatedSubBlock
})
newBlock.subBlocks = updatedSubBlocks
@@ -871,12 +892,17 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
const newId = edgeIdMapping.get(edge.id)!
const newSource = blockIdMapping.get(edge.source) || edge.source
const newTarget = blockIdMapping.get(edge.target) || edge.target
const newSourceHandle =
edge.sourceHandle && blockIdMapping.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
newEdges.push({
...edge,
id: newId,
source: newSource,
target: newTarget,
sourceHandle: newSourceHandle,
})
})

View File

@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
@@ -363,13 +364,15 @@ export function regenerateWorkflowIds(
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
// First pass: generate new IDs
// First pass: generate new IDs and remap condition/router IDs in subBlocks
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
const newId = uuidv4()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
nameMap.set(oldNormalizedName, oldNormalizedName)
newBlocks[newId] = { ...block, id: newId }
const newBlock = { ...block, id: newId, subBlocks: JSON.parse(JSON.stringify(block.subBlocks)) }
remapConditionIds(newBlock.subBlocks, {}, oldId, newId)
newBlocks[newId] = newBlock
})
// Second pass: update parentId references
@@ -385,12 +388,21 @@ export function regenerateWorkflowIds(
}
})
const newEdges = workflowState.edges.map((edge) => ({
...edge,
id: uuidv4(),
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
const newEdges = workflowState.edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: uuidv4(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
if (workflowState.loops) {
@@ -429,6 +441,37 @@ export function regenerateWorkflowIds(
}
}
/**
* Remaps condition/router block IDs within subBlock values when a block is duplicated.
* Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data).
*/
export function remapConditionIds(
subBlocks: Record<string, SubBlockState>,
subBlockValues: Record<string, unknown>,
oldBlockId: string,
newBlockId: string
): void {
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue
const value = subBlockValues[subBlockId] ?? subBlock.value
if (typeof value !== 'string') continue
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) continue
if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
const newValue = JSON.stringify(parsed)
subBlock.value = newValue
subBlockValues[subBlockId] = newValue
}
} catch {
// Not valid JSON, skip
}
}
}
export function regenerateBlockIds(
blocks: Record<string, BlockState>,
edges: Edge[],
@@ -497,6 +540,7 @@ export function regenerateBlockIds(
id: newId,
name: newName,
position: newPosition,
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
// Temporarily keep data as-is, we'll fix parentId in second pass
data: block.data ? { ...block.data } : block.data,
// Duplicated blocks are always unlocked so users can edit them
@@ -510,6 +554,9 @@ export function regenerateBlockIds(
if (subBlockValues[oldId]) {
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
}
// Remap condition/router IDs in the duplicated block
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
})
// Second pass: update parentId references for nested blocks
@@ -542,12 +589,21 @@ export function regenerateBlockIds(
}
})
const newEdges = edges.map((edge) => ({
...edge,
id: uuidv4(),
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
const newEdges = edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: uuidv4(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
Object.entries(loops).forEach(([oldLoopId, loop]) => {

View File

@@ -12,6 +12,7 @@ import {
filterValidEdges,
getUniqueBlockName,
mergeSubblockState,
remapConditionIds,
} from '@/stores/workflows/utils'
import type {
Position,
@@ -611,6 +612,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
{}
)
// Remap condition/router IDs in the duplicated subBlocks
const clonedSubBlockValues = activeWorkflowId
? JSON.parse(
JSON.stringify(
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
)
)
: {}
remapConditionIds(
newSubBlocks as Record<string, SubBlockState>,
clonedSubBlockValues,
id,
newId
)
const newState = {
blocks: {
...get().blocks,
@@ -630,14 +646,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
[newId]: clonedSubBlockValues,
},
},
}))

View File

@@ -0,0 +1,74 @@
import type { FathomGetSummaryParams, FathomGetSummaryResponse } from '@/tools/fathom/types'
import type { ToolConfig } from '@/tools/types'
export const getSummaryTool: ToolConfig<FathomGetSummaryParams, FathomGetSummaryResponse> = {
id: 'fathom_get_summary',
name: 'Fathom Get Summary',
description: 'Get the call summary for a specific meeting recording.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Fathom API Key',
},
recordingId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The recording ID of the meeting',
},
},
request: {
url: (params) =>
`https://api.fathom.ai/external/v1/recordings/${encodeURIComponent(params.recordingId.trim())}/summary`,
method: 'GET',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return {
success: false,
error:
(errorData as Record<string, string>).message ||
`Fathom API error: ${response.status} ${response.statusText}`,
output: {
template_name: null,
markdown_formatted: null,
},
}
}
const data = await response.json()
const summary = data.summary ?? data
return {
success: true,
output: {
template_name: summary.template_name ?? null,
markdown_formatted: summary.markdown_formatted ?? null,
},
}
},
outputs: {
template_name: {
type: 'string',
description: 'Name of the summary template used',
optional: true,
},
markdown_formatted: {
type: 'string',
description: 'Markdown-formatted summary text',
optional: true,
},
},
}

View File

@@ -0,0 +1,95 @@
import type { FathomGetTranscriptParams, FathomGetTranscriptResponse } from '@/tools/fathom/types'
import type { ToolConfig } from '@/tools/types'
export const getTranscriptTool: ToolConfig<FathomGetTranscriptParams, FathomGetTranscriptResponse> =
{
id: 'fathom_get_transcript',
name: 'Fathom Get Transcript',
description: 'Get the full transcript for a specific meeting recording.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Fathom API Key',
},
recordingId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The recording ID of the meeting',
},
},
request: {
url: (params) =>
`https://api.fathom.ai/external/v1/recordings/${encodeURIComponent(params.recordingId.trim())}/transcript`,
method: 'GET',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return {
success: false,
error:
(errorData as Record<string, string>).message ||
`Fathom API error: ${response.status} ${response.statusText}`,
output: {
transcript: [],
},
}
}
const data = await response.json()
const transcript = (data.transcript ?? []).map(
(entry: { speaker?: Record<string, unknown>; text?: string; timestamp?: string }) => ({
speaker: {
display_name: entry.speaker?.display_name ?? '',
matched_calendar_invitee_email: entry.speaker?.matched_calendar_invitee_email ?? null,
},
text: entry.text ?? '',
timestamp: entry.timestamp ?? '',
})
)
return {
success: true,
output: {
transcript,
},
}
},
outputs: {
transcript: {
type: 'array',
description: 'Array of transcript entries with speaker, text, and timestamp',
items: {
type: 'object',
properties: {
speaker: {
type: 'object',
description: 'Speaker information',
properties: {
display_name: { type: 'string', description: 'Speaker display name' },
matched_calendar_invitee_email: {
type: 'string',
description: 'Matched calendar invitee email',
optional: true,
},
},
},
text: { type: 'string', description: 'Transcript text' },
timestamp: { type: 'string', description: 'Timestamp (HH:MM:SS)' },
},
},
},
},
}

View File

@@ -0,0 +1,13 @@
import { getSummaryTool } from '@/tools/fathom/get_summary'
import { getTranscriptTool } from '@/tools/fathom/get_transcript'
import { listMeetingsTool } from '@/tools/fathom/list_meetings'
import { listTeamMembersTool } from '@/tools/fathom/list_team_members'
import { listTeamsTool } from '@/tools/fathom/list_teams'
export const fathomGetSummaryTool = getSummaryTool
export const fathomGetTranscriptTool = getTranscriptTool
export const fathomListMeetingsTool = listMeetingsTool
export const fathomListTeamMembersTool = listTeamMembersTool
export const fathomListTeamsTool = listTeamsTool
export * from './types'

View File

@@ -0,0 +1,174 @@
import type { FathomListMeetingsParams, FathomListMeetingsResponse } from '@/tools/fathom/types'
import type { ToolConfig } from '@/tools/types'
export const listMeetingsTool: ToolConfig<FathomListMeetingsParams, FathomListMeetingsResponse> = {
id: 'fathom_list_meetings',
name: 'Fathom List Meetings',
description: 'List recent meetings recorded by the user or shared to their team.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Fathom API Key',
},
includeSummary: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Include meeting summary (true/false)',
},
includeTranscript: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Include meeting transcript (true/false)',
},
includeActionItems: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Include action items (true/false)',
},
includeCrmMatches: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Include linked CRM matches (true/false)',
},
createdAfter: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter meetings created after this ISO 8601 timestamp',
},
createdBefore: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter meetings created before this ISO 8601 timestamp',
},
recordedBy: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by recorder email address',
},
teams: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by team name',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from a previous response',
},
},
request: {
url: (params) => {
const url = new URL('https://api.fathom.ai/external/v1/meetings')
if (params.includeSummary === 'true') url.searchParams.append('include_summary', 'true')
if (params.includeTranscript === 'true') url.searchParams.append('include_transcript', 'true')
if (params.includeActionItems === 'true')
url.searchParams.append('include_action_items', 'true')
if (params.includeCrmMatches === 'true')
url.searchParams.append('include_crm_matches', 'true')
if (params.createdAfter) url.searchParams.append('created_after', params.createdAfter)
if (params.createdBefore) url.searchParams.append('created_before', params.createdBefore)
if (params.recordedBy) url.searchParams.append('recorded_by[]', params.recordedBy)
if (params.teams) url.searchParams.append('teams[]', params.teams)
if (params.cursor) url.searchParams.append('cursor', params.cursor)
return url.toString()
},
method: 'GET',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return {
success: false,
error:
(errorData as Record<string, string>).message ||
`Fathom API error: ${response.status} ${response.statusText}`,
output: {
meetings: [],
next_cursor: null,
},
}
}
const data = await response.json()
const meetings = (data.items ?? []).map(
(meeting: Record<string, unknown> & { recorded_by?: Record<string, unknown> }) => ({
title: meeting.title ?? '',
meeting_title: meeting.meeting_title ?? null,
recording_id: meeting.recording_id ?? null,
url: meeting.url ?? '',
share_url: meeting.share_url ?? '',
created_at: meeting.created_at ?? '',
scheduled_start_time: meeting.scheduled_start_time ?? null,
scheduled_end_time: meeting.scheduled_end_time ?? null,
recording_start_time: meeting.recording_start_time ?? null,
recording_end_time: meeting.recording_end_time ?? null,
transcript_language: meeting.transcript_language ?? '',
calendar_invitees_domains_type: meeting.calendar_invitees_domains_type ?? null,
recorded_by: meeting.recorded_by
? {
name: meeting.recorded_by.name ?? '',
email: meeting.recorded_by.email ?? '',
email_domain: meeting.recorded_by.email_domain ?? '',
team: meeting.recorded_by.team ?? null,
}
: null,
calendar_invitees: (meeting.calendar_invitees as Array<Record<string, unknown>>) ?? [],
default_summary: meeting.default_summary ?? null,
transcript: meeting.transcript ?? null,
action_items: meeting.action_items ?? null,
crm_matches: meeting.crm_matches ?? null,
})
)
return {
success: true,
output: {
meetings,
next_cursor: data.next_cursor ?? null,
},
}
},
outputs: {
meetings: {
type: 'array',
description: 'List of meetings',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'Meeting title' },
recording_id: { type: 'number', description: 'Unique recording ID' },
url: { type: 'string', description: 'URL to view the meeting' },
share_url: { type: 'string', description: 'Shareable URL' },
created_at: { type: 'string', description: 'Creation timestamp' },
transcript_language: { type: 'string', description: 'Transcript language' },
},
},
},
next_cursor: {
type: 'string',
description: 'Pagination cursor for next page',
optional: true,
},
},
}

View File

@@ -0,0 +1,103 @@
import type {
FathomListTeamMembersParams,
FathomListTeamMembersResponse,
} from '@/tools/fathom/types'
import type { ToolConfig } from '@/tools/types'
export const listTeamMembersTool: ToolConfig<
FathomListTeamMembersParams,
FathomListTeamMembersResponse
> = {
id: 'fathom_list_team_members',
name: 'Fathom List Team Members',
description: 'List team members in your Fathom organization.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Fathom API Key',
},
teams: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team name to filter by',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from a previous response',
},
},
request: {
url: (params) => {
const url = new URL('https://api.fathom.ai/external/v1/team_members')
if (params.teams) url.searchParams.append('team', params.teams)
if (params.cursor) url.searchParams.append('cursor', params.cursor)
return url.toString()
},
method: 'GET',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return {
success: false,
error:
(errorData as Record<string, string>).message ||
`Fathom API error: ${response.status} ${response.statusText}`,
output: {
members: [],
next_cursor: null,
},
}
}
const data = await response.json()
const members = (data.items ?? []).map(
(member: { name?: string; email?: string; created_at?: string }) => ({
name: member.name ?? '',
email: member.email ?? '',
created_at: member.created_at ?? '',
})
)
return {
success: true,
output: {
members,
next_cursor: data.next_cursor ?? null,
},
}
},
outputs: {
members: {
type: 'array',
description: 'List of team members',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Team member name' },
email: { type: 'string', description: 'Team member email' },
created_at: { type: 'string', description: 'Date the member was added' },
},
},
},
next_cursor: {
type: 'string',
description: 'Pagination cursor for next page',
optional: true,
},
},
}

View File

@@ -0,0 +1,86 @@
import type { FathomListTeamsParams, FathomListTeamsResponse } from '@/tools/fathom/types'
import type { ToolConfig } from '@/tools/types'
export const listTeamsTool: ToolConfig<FathomListTeamsParams, FathomListTeamsResponse> = {
id: 'fathom_list_teams',
name: 'Fathom List Teams',
description: 'List teams in your Fathom organization.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Fathom API Key',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination cursor from a previous response',
},
},
request: {
url: (params) => {
const url = new URL('https://api.fathom.ai/external/v1/teams')
if (params.cursor) url.searchParams.append('cursor', params.cursor)
return url.toString()
},
method: 'GET',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return {
success: false,
error:
(errorData as Record<string, string>).message ||
`Fathom API error: ${response.status} ${response.statusText}`,
output: {
teams: [],
next_cursor: null,
},
}
}
const data = await response.json()
const teams = (data.items ?? []).map((team: { name?: string; created_at?: string }) => ({
name: team.name ?? '',
created_at: team.created_at ?? '',
}))
return {
success: true,
output: {
teams,
next_cursor: data.next_cursor ?? null,
},
}
},
outputs: {
teams: {
type: 'array',
description: 'List of teams',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Team name' },
created_at: { type: 'string', description: 'Date the team was created' },
},
},
},
next_cursor: {
type: 'string',
description: 'Pagination cursor for next page',
optional: true,
},
},
}

View File

@@ -0,0 +1,127 @@
import type { ToolResponse } from '@/tools/types'
export interface FathomBaseParams {
apiKey: string
}
export interface FathomListMeetingsParams extends FathomBaseParams {
includeSummary?: string
includeTranscript?: string
includeActionItems?: string
includeCrmMatches?: string
createdAfter?: string
createdBefore?: string
recordedBy?: string
teams?: string
cursor?: string
}
export interface FathomListMeetingsResponse extends ToolResponse {
output: {
meetings: Array<{
title: string
meeting_title: string | null
recording_id: number | null
url: string
share_url: string
created_at: string
scheduled_start_time: string | null
scheduled_end_time: string | null
recording_start_time: string | null
recording_end_time: string | null
transcript_language: string
calendar_invitees_domains_type: string | null
recorded_by: { name: string; email: string; email_domain: string; team: string | null } | null
calendar_invitees: Array<{
name: string | null
email: string
email_domain: string | null
is_external: boolean
matched_speaker_display_name: string | null
}>
default_summary: { template_name: string | null; markdown_formatted: string | null } | null
transcript: Array<{
speaker: { display_name: string; matched_calendar_invitee_email: string | null }
text: string
timestamp: string
}> | null
action_items: Array<{
description: string
user_generated: boolean
completed: boolean
recording_timestamp: string
recording_playback_url: string
assignee: { name: string | null; email: string | null; team: string | null }
}> | null
crm_matches: {
contacts: Array<{ name: string; email: string; record_url: string }>
companies: Array<{ name: string; record_url: string }>
deals: Array<{ name: string; amount: number; record_url: string }>
error: string | null
} | null
}>
next_cursor: string | null
}
}
export interface FathomGetSummaryParams extends FathomBaseParams {
recordingId: string
}
export interface FathomGetSummaryResponse extends ToolResponse {
output: {
template_name: string | null
markdown_formatted: string | null
}
}
export interface FathomGetTranscriptParams extends FathomBaseParams {
recordingId: string
}
export interface FathomGetTranscriptResponse extends ToolResponse {
output: {
transcript: Array<{
speaker: { display_name: string; matched_calendar_invitee_email: string | null }
text: string
timestamp: string
}>
}
}
export interface FathomListTeamMembersParams extends FathomBaseParams {
teams?: string
cursor?: string
}
export interface FathomListTeamMembersResponse extends ToolResponse {
output: {
members: Array<{
name: string
email: string
created_at: string
}>
next_cursor: string | null
}
}
export interface FathomListTeamsParams extends FathomBaseParams {
cursor?: string
}
export interface FathomListTeamsResponse extends ToolResponse {
output: {
teams: Array<{
name: string
created_at: string
}>
next_cursor: string | null
}
}
export type FathomResponse =
| FathomListMeetingsResponse
| FathomGetSummaryResponse
| FathomGetTranscriptResponse
| FathomListTeamMembersResponse
| FathomListTeamsResponse

View File

@@ -0,0 +1,36 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { encodeRfc2047 } from './utils'
describe('encodeRfc2047', () => {
it('returns ASCII text unchanged', () => {
expect(encodeRfc2047('Simple ASCII Subject')).toBe('Simple ASCII Subject')
})
it('returns empty string unchanged', () => {
expect(encodeRfc2047('')).toBe('')
})
it('encodes emojis as RFC 2047 base64', () => {
const result = encodeRfc2047('Time to Stretch! 🧘')
expect(result).toBe('=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?=')
})
it('round-trips non-ASCII subjects correctly', () => {
const subjects = ['Hello 世界', 'Café résumé', '🎉🎊🎈 Party!', '今週のミーティング']
for (const subject of subjects) {
const encoded = encodeRfc2047(subject)
const match = encoded.match(/^=\?UTF-8\?B\?(.+)\?=$/)
expect(match).not.toBeNull()
const decoded = Buffer.from(match![1], 'base64').toString('utf-8')
expect(decoded).toBe(subject)
}
})
it('does not double-encode already-encoded subjects', () => {
const alreadyEncoded = '=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?='
expect(encodeRfc2047(alreadyEncoded)).toBe(alreadyEncoded)
})
})

View File

@@ -294,6 +294,19 @@ function generateBoundary(): string {
return `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
}
/**
* Encode a header value using RFC 2047 Base64 encoding if it contains non-ASCII characters.
* This matches Google's own Gmail API sample: `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`
* @see https://github.com/googleapis/google-api-nodejs-client/blob/main/samples/gmail/send.js
*/
export function encodeRfc2047(value: string): string {
// eslint-disable-next-line no-control-regex
if (/^[\x00-\x7F]*$/.test(value)) {
return value
}
return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`
}
/**
* Encode string or buffer to base64url format (URL-safe base64)
* Gmail API requires base64url encoding for the raw message field
@@ -333,7 +346,7 @@ export function buildSimpleEmailMessage(params: {
emailHeaders.push(`Bcc: ${bcc}`)
}
emailHeaders.push(`Subject: ${subject || ''}`)
emailHeaders.push(`Subject: ${encodeRfc2047(subject || '')}`)
if (inReplyTo) {
emailHeaders.push(`In-Reply-To: ${inReplyTo}`)
@@ -380,7 +393,7 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
if (bcc) {
messageParts.push(`Bcc: ${bcc}`)
}
messageParts.push(`Subject: ${subject || ''}`)
messageParts.push(`Subject: ${encodeRfc2047(subject || '')}`)
if (inReplyTo) {
messageParts.push(`In-Reply-To: ${inReplyTo}`)

View File

@@ -137,8 +137,11 @@ export const jiraSearchIssuesTool: ToolConfig<JiraSearchIssuesParams, JiraSearch
if (params.nextPageToken) query.set('nextPageToken', params.nextPageToken)
if (typeof params.maxResults === 'number')
query.set('maxResults', String(params.maxResults))
if (Array.isArray(params.fields) && params.fields.length > 0)
if (Array.isArray(params.fields) && params.fields.length > 0) {
query.set('fields', params.fields.join(','))
} else {
query.set('fields', '*all')
}
const qs = query.toString()
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/search/jql${qs ? `?${qs}` : ''}`
}
@@ -159,8 +162,11 @@ export const jiraSearchIssuesTool: ToolConfig<JiraSearchIssuesParams, JiraSearch
if (params?.jql) query.set('jql', params.jql)
if (params?.nextPageToken) query.set('nextPageToken', params.nextPageToken)
if (typeof params?.maxResults === 'number') query.set('maxResults', String(params.maxResults))
if (Array.isArray(params?.fields) && params.fields.length > 0)
if (Array.isArray(params?.fields) && params.fields.length > 0) {
query.set('fields', params.fields.join(','))
} else {
query.set('fields', '*all')
}
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${query.toString()}`
const searchResponse = await fetch(searchUrl, {
method: 'GET',

View File

@@ -446,6 +446,13 @@ import {
exaResearchTool,
exaSearchTool,
} from '@/tools/exa'
import {
fathomGetSummaryTool,
fathomGetTranscriptTool,
fathomListMeetingsTool,
fathomListTeamMembersTool,
fathomListTeamsTool,
} from '@/tools/fathom'
import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file'
import {
firecrawlAgentTool,
@@ -3666,6 +3673,11 @@ export const tools: Record<string, ToolConfig> = {
knowledge_create_document: knowledgeCreateDocumentTool,
search_tool: searchTool,
elevenlabs_tts: elevenLabsTtsTool,
fathom_list_meetings: fathomListMeetingsTool,
fathom_get_summary: fathomGetSummaryTool,
fathom_get_transcript: fathomGetTranscriptTool,
fathom_list_team_members: fathomListTeamMembersTool,
fathom_list_teams: fathomListTeamsTool,
stt_whisper: whisperSttTool,
stt_whisper_v2: whisperSttV2Tool,
stt_deepgram: deepgramSttTool,

View File

@@ -85,6 +85,7 @@ export const slackGetUserTool: ToolConfig<SlackGetUserParams, SlackGetUserRespon
first_name: profile.first_name || '',
last_name: profile.last_name || '',
title: profile.title || '',
email: profile.email || '',
phone: profile.phone || '',
skype: profile.skype || '',
is_bot: user.is_bot || false,

View File

@@ -93,6 +93,7 @@ export const slackListUsersTool: ToolConfig<SlackListUsersParams, SlackListUsers
name: user.name,
real_name: user.real_name || user.profile?.real_name || '',
display_name: user.profile?.display_name || '',
email: user.profile?.email || '',
is_bot: user.is_bot || false,
is_admin: user.is_admin || false,
is_owner: user.is_owner || false,

View File

@@ -376,6 +376,11 @@ export const USER_OUTPUT_PROPERTIES = {
title: { type: 'string', description: 'Job title', optional: true },
phone: { type: 'string', description: 'Phone number', optional: true },
skype: { type: 'string', description: 'Skype handle', optional: true },
email: {
type: 'string',
description: 'Email address (requires users:read.email scope)',
optional: true,
},
is_bot: { type: 'boolean', description: 'Whether the user is a bot' },
is_admin: { type: 'boolean', description: 'Whether the user is a workspace admin' },
is_owner: { type: 'boolean', description: 'Whether the user is the workspace owner' },
@@ -438,6 +443,11 @@ export const USER_SUMMARY_OUTPUT_PROPERTIES = {
name: { type: 'string', description: 'Username (handle)' },
real_name: { type: 'string', description: 'Full real name' },
display_name: { type: 'string', description: 'Display name shown in Slack' },
email: {
type: 'string',
description: 'Email address (requires users:read.email scope)',
optional: true,
},
is_bot: { type: 'boolean', description: 'Whether the user is a bot' },
is_admin: { type: 'boolean', description: 'Whether the user is a workspace admin' },
is_owner: { type: 'boolean', description: 'Whether the user is the workspace owner' },
@@ -953,6 +963,7 @@ export interface SlackUser {
title?: string
phone?: string
skype?: string
email: string
is_bot: boolean
is_admin: boolean
is_owner: boolean

View File

@@ -0,0 +1,41 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { POLLING_PROVIDERS } from '@/triggers/constants'
import { TRIGGER_REGISTRY } from '@/triggers/registry'
describe('POLLING_PROVIDERS sync with TriggerConfig.polling', () => {
it('matches every trigger with polling: true in the registry', () => {
const registryPollingProviders = new Set(
Object.values(TRIGGER_REGISTRY)
.filter((t) => t.polling === true)
.map((t) => t.provider)
)
expect(POLLING_PROVIDERS).toEqual(registryPollingProviders)
})
it('no trigger with polling: true is missing from POLLING_PROVIDERS', () => {
const missing: string[] = []
for (const trigger of Object.values(TRIGGER_REGISTRY)) {
if (trigger.polling && !POLLING_PROVIDERS.has(trigger.provider)) {
missing.push(`${trigger.id} (provider: ${trigger.provider})`)
}
}
expect(missing, `Triggers with polling: true missing from POLLING_PROVIDERS`).toEqual([])
})
it('no POLLING_PROVIDERS entry lacks a polling: true trigger in the registry', () => {
const extra: string[] = []
for (const provider of POLLING_PROVIDERS) {
const hasTrigger = Object.values(TRIGGER_REGISTRY).some(
(t) => t.provider === provider && t.polling === true
)
if (!hasTrigger) {
extra.push(provider)
}
}
expect(extra, `POLLING_PROVIDERS entries with no matching polling trigger`).toEqual([])
})
})

View File

@@ -35,3 +35,15 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [
* This prevents runaway errors from continuously executing failing workflows.
*/
export const MAX_CONSECUTIVE_FAILURES = 100
/**
* Set of webhook provider names that use polling-based triggers.
* Mirrors the `polling: true` flag on TriggerConfig entries.
* Used to route execution: polling providers use the full job queue
* (Trigger.dev), non-polling providers execute inline.
*/
export const POLLING_PROVIDERS = new Set(['gmail', 'outlook', 'rss', 'imap'])
export function isPollingWebhookProvider(provider: string): boolean {
return POLLING_PROVIDERS.has(provider)
}

View File

@@ -0,0 +1,2 @@
export { fathomNewMeetingTrigger } from './new_meeting'
export { fathomWebhookTrigger } from './webhook'

View File

@@ -0,0 +1,128 @@
import { FathomIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildMeetingOutputs, fathomSetupInstructions } from './utils'
export const fathomNewMeetingTrigger: TriggerConfig = {
id: 'fathom_new_meeting',
name: 'Fathom New Meeting Content',
provider: 'fathom',
description: 'Trigger workflow when new meeting content is ready in Fathom',
version: '1.0.0',
icon: FathomIcon,
subBlocks: [
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Fathom API key',
description: 'Required to create the webhook in Fathom.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
{
id: 'triggeredFor',
title: 'Trigger For',
type: 'dropdown',
options: [
{ label: 'My Recordings', id: 'my_recordings' },
{ label: 'Shared External Recordings', id: 'shared_external_recordings' },
{ label: 'My Shared With Team Recordings', id: 'my_shared_with_team_recordings' },
{ label: 'Shared Team Recordings', id: 'shared_team_recordings' },
],
value: () => 'my_recordings',
description: 'Which recording types should trigger this webhook.',
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
{
id: 'includeSummary',
title: 'Include Summary',
type: 'switch',
description: 'Include the meeting summary in the webhook payload.',
defaultValue: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
{
id: 'includeTranscript',
title: 'Include Transcript',
type: 'switch',
description: 'Include the full transcript in the webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
{
id: 'includeActionItems',
title: 'Include Action Items',
type: 'switch',
description: 'Include action items extracted from the meeting.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
{
id: 'includeCrmMatches',
title: 'Include CRM Matches',
type: 'switch',
description: 'Include matched CRM contacts, companies, and deals from your linked CRM.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'fathom_new_meeting',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: fathomSetupInstructions('New Meeting Content'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_new_meeting',
},
},
],
outputs: buildMeetingOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -0,0 +1,123 @@
import type { TriggerOutput } from '@/triggers/types'
/**
* Shared trigger dropdown options for all Fathom triggers
*/
export const fathomTriggerOptions = [
{ label: 'New Meeting Content', id: 'fathom_new_meeting' },
{ label: 'General Webhook (All Events)', id: 'fathom_webhook' },
]
/**
* Generate setup instructions for a specific Fathom event type
*/
export function fathomSetupInstructions(eventType: string): string {
const instructions = [
'Enter your Fathom API Key above.',
'You can find or create your API key in Fathom at <strong>Settings > Integrations > API</strong>. See the <a href="https://developers.fathom.ai/" target="_blank" rel="noopener noreferrer">Fathom API documentation</a> for details.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Fathom for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join('')
}
/**
* Build output schema for meeting content events.
* Fathom webhook payload delivers meeting data including summary, transcript, and action items
* based on the include flags set during webhook creation.
*/
export function buildMeetingOutputs(): Record<string, TriggerOutput> {
return {
title: {
type: 'string',
description: 'Meeting title',
},
meeting_title: {
type: 'string',
description: 'Calendar event title',
},
recording_id: {
type: 'number',
description: 'Unique recording ID',
},
url: {
type: 'string',
description: 'URL to view the meeting in Fathom',
},
share_url: {
type: 'string',
description: 'Shareable URL for the meeting',
},
created_at: {
type: 'string',
description: 'ISO 8601 creation timestamp',
},
scheduled_start_time: {
type: 'string',
description: 'Scheduled start time',
},
scheduled_end_time: {
type: 'string',
description: 'Scheduled end time',
},
recording_start_time: {
type: 'string',
description: 'Recording start time',
},
recording_end_time: {
type: 'string',
description: 'Recording end time',
},
transcript_language: {
type: 'string',
description: 'Language of the transcript',
},
calendar_invitees_domains_type: {
type: 'string',
description: 'Domain type: only_internal or one_or_more_external',
},
recorded_by: {
type: 'object',
description: 'Recorder details',
name: { type: 'string', description: 'Name of the recorder' },
email: { type: 'string', description: 'Email of the recorder' },
},
calendar_invitees: {
type: 'array',
description: 'Array of calendar invitees with name and email',
},
default_summary: {
type: 'object',
description: 'Meeting summary',
template_name: { type: 'string', description: 'Summary template name' },
markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' },
},
transcript: {
type: 'array',
description: 'Array of transcript entries with speaker, text, and timestamp',
},
action_items: {
type: 'array',
description: 'Array of action items extracted from the meeting',
},
crm_matches: {
type: 'json',
description: 'Matched CRM contacts, companies, and deals from linked CRM',
},
} as Record<string, TriggerOutput>
}
/**
* Build output schema for generic webhook events.
* Fathom only has one webhook event type (new meeting content ready) and the payload
* is the Meeting object directly (no wrapping), so outputs match buildMeetingOutputs.
*/
export function buildGenericOutputs(): Record<string, TriggerOutput> {
return buildMeetingOutputs()
}

View File

@@ -0,0 +1,128 @@
import { FathomIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildGenericOutputs, fathomSetupInstructions } from './utils'
export const fathomWebhookTrigger: TriggerConfig = {
id: 'fathom_webhook',
name: 'Fathom Webhook',
provider: 'fathom',
description: 'Generic webhook trigger for all Fathom events',
version: '1.0.0',
icon: FathomIcon,
subBlocks: [
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Fathom API key',
description: 'Required to create the webhook in Fathom.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
{
id: 'triggeredFor',
title: 'Trigger For',
type: 'dropdown',
options: [
{ label: 'My Recordings', id: 'my_recordings' },
{ label: 'Shared External Recordings', id: 'shared_external_recordings' },
{ label: 'My Shared With Team Recordings', id: 'my_shared_with_team_recordings' },
{ label: 'Shared Team Recordings', id: 'shared_team_recordings' },
],
value: () => 'my_recordings',
description: 'Which recording types should trigger this webhook.',
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
{
id: 'includeSummary',
title: 'Include Summary',
type: 'switch',
description: 'Include the meeting summary in the webhook payload.',
defaultValue: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
{
id: 'includeTranscript',
title: 'Include Transcript',
type: 'switch',
description: 'Include the full transcript in the webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
{
id: 'includeActionItems',
title: 'Include Action Items',
type: 'switch',
description: 'Include action items extracted from the meeting.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
{
id: 'includeCrmMatches',
title: 'Include CRM Matches',
type: 'switch',
description: 'Include matched CRM contacts, companies, and deals from your linked CRM.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'fathom_webhook',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: fathomSetupInstructions('All Events'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'fathom_webhook',
},
},
],
outputs: buildGenericOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -49,6 +49,49 @@ export const genericWebhookTrigger: TriggerConfig = {
required: false,
mode: 'trigger',
},
{
id: 'idempotencyField',
title: 'Deduplication Field (Optional)',
type: 'short-input',
placeholder: 'e.g. event.id',
description:
'Dot-notation path to a unique field in the payload for deduplication. If the same value is seen within 7 days, the duplicate webhook will be skipped.',
required: false,
mode: 'trigger',
},
{
id: 'responseMode',
title: 'Acknowledgement',
type: 'dropdown',
options: [
{ label: 'Default', id: 'default' },
{ label: 'Custom', id: 'custom' },
],
defaultValue: 'default',
mode: 'trigger',
},
{
id: 'responseStatusCode',
title: 'Response Status Code',
type: 'short-input',
placeholder: '200 (default)',
description:
'HTTP status code (100599) to return to the webhook caller. Defaults to 200 if empty or invalid.',
required: false,
mode: 'trigger',
condition: { field: 'responseMode', value: 'custom' },
},
{
id: 'responseBody',
title: 'Response Body',
type: 'code',
language: 'json',
placeholder: '{"ok": true}',
description: 'JSON body to return to the webhook caller. Leave empty for no body.',
required: false,
mode: 'trigger',
condition: { field: 'responseMode', value: 'custom' },
},
{
id: 'inputFormat',
title: 'Input Format',
@@ -76,7 +119,7 @@ export const genericWebhookTrigger: TriggerConfig = {
'The webhook will receive any HTTP method (GET, POST, PUT, DELETE, etc.).',
'All request data (headers, body, query parameters) will be available in your workflow.',
'If authentication is enabled, include the token in requests using either the custom header or "Authorization: Bearer TOKEN".',
'Common fields like "event", "id", and "data" will be automatically extracted from the payload when available.',
'To deduplicate incoming events, set the Deduplication Field to the dot-notation path of a unique identifier in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.',
]
.map(
(instruction, index) =>

View File

@@ -30,6 +30,7 @@ export const gmailPollingTrigger: TriggerConfig = {
description: 'Triggers when new emails are received in Gmail (requires Gmail credentials)',
version: '1.0.0',
icon: GmailIcon,
polling: true,
subBlocks: [
{

View File

@@ -12,6 +12,7 @@ export const imapPollingTrigger: TriggerConfig = {
description: 'Triggers when new emails are received via IMAP (works with any email provider)',
version: '1.0.0',
icon: MailServerIcon,
polling: true,
subBlocks: [
// Connection settings

View File

@@ -24,6 +24,7 @@ export const outlookPollingTrigger: TriggerConfig = {
description: 'Triggers when new emails are received in Outlook (requires Microsoft credentials)',
version: '1.0.0',
icon: OutlookIcon,
polling: true,
subBlocks: [
{

View File

@@ -59,6 +59,7 @@ import {
confluenceSpaceUpdatedTrigger,
confluenceWebhookTrigger,
} from '@/triggers/confluence'
import { fathomNewMeetingTrigger, fathomWebhookTrigger } from '@/triggers/fathom'
import { firefliesTranscriptionCompleteTrigger } from '@/triggers/fireflies'
import { genericWebhookTrigger } from '@/triggers/generic'
import {
@@ -226,6 +227,8 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
github_release_published: githubReleasePublishedTrigger,
github_workflow_run: githubWorkflowRunTrigger,
fireflies_transcription_complete: firefliesTranscriptionCompleteTrigger,
fathom_new_meeting: fathomNewMeetingTrigger,
fathom_webhook: fathomWebhookTrigger,
gmail_poller: gmailPollingTrigger,
grain_webhook: grainWebhookTrigger,
grain_recording_created: grainRecordingCreatedTrigger,

View File

@@ -8,6 +8,7 @@ export const rssPollingTrigger: TriggerConfig = {
description: 'Triggers when new items are published to an RSS feed',
version: '1.0.0',
icon: RssIcon,
polling: true,
subBlocks: [
{

View File

@@ -25,6 +25,9 @@ export interface TriggerConfig {
method?: 'POST' | 'GET' | 'PUT' | 'DELETE'
headers?: Record<string, string>
}
/** When true, this trigger is poll-based (cron-driven) rather than push-based. */
polling?: boolean
}
export interface TriggerRegistry {