Compare commits

..

10 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
41 changed files with 1907 additions and 210 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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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,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

@@ -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,