+
Select a block to see its full configuration
)}
diff --git a/apps/docs/content/docs/de/tools/a2a.mdx b/apps/docs/content/docs/de/tools/a2a.mdx
deleted file mode 100644
index bac4105ba0a..00000000000
--- a/apps/docs/content/docs/de/tools/a2a.mdx
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: A2A
-description: Interagiere mit externen A2A-kompatiblen Agenten
----
-
-import { BlockInfoCard } from "@/components/ui/block-info-card"
-
-
-
-{/* MANUAL-CONTENT-START:intro */}
-Das A2A-Protokoll (Agent-to-Agent) ermöglicht es Sim, mit externen KI-Agenten und Systemen zu interagieren, die A2A-kompatible APIs implementieren. Mit A2A kannst du Sims Automatisierungen und Workflows mit Remote-Agenten verbinden – wie LLM-gestützten Bots, Microservices und anderen KI-basierten Tools – unter Verwendung eines standardisierten Nachrichtenformats.
-
-Mit den A2A-Tools in Sim kannst du:
-
-- **Nachrichten an externe Agenten senden**: Kommuniziere direkt mit Remote-Agenten und übermittle Prompts, Befehle oder Daten.
-- **Antworten empfangen und streamen**: Erhalte strukturierte Antworten, Artefakte oder Echtzeit-Updates vom Agenten, während die Aufgabe fortschreitet.
-- **Gespräche oder Aufgaben fortsetzen**: Führe mehrstufige Konversationen oder Workflows fort, indem du auf Aufgaben- und Kontext-IDs verweist.
-- **Drittanbieter-KI und Automatisierung integrieren**: Nutze externe A2A-kompatible Dienste als Teil deiner Sim-Workflows.
-
-Diese Funktionen ermöglichen es dir, fortgeschrittene Workflows zu erstellen, die Sims native Fähigkeiten mit der Intelligenz und Automatisierung externer KIs oder benutzerdefinierter Agenten kombinieren. Um A2A-Integrationen zu nutzen, benötigst du die Endpunkt-URL des externen Agenten und, falls erforderlich, einen API-Schlüssel oder Zugangsdaten.
-{/* MANUAL-CONTENT-END */}
-
-## Nutzungsanleitung
-
-Verwende das A2A-Protokoll (Agent-to-Agent), um mit externen KI-Agenten zu interagieren.
-
-## Tools
-
-### `a2a_send_message`
-
-Sende eine Nachricht an einen externen A2A-kompatiblen Agenten.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die A2A-Agenten-Endpunkt-URL |
-| `message` | string | Ja | Nachricht, die an den Agenten gesendet werden soll |
-| `taskId` | string | Nein | Aufgaben-ID zum Fortsetzen einer bestehenden Aufgabe |
-| `contextId` | string | Nein | Kontext-ID für Gesprächskontinuität |
-| `data` | string | Nein | Strukturierte Daten, die mit der Nachricht einbezogen werden sollen \(JSON-String\) |
-| `files` | array | Nein | Dateien, die mit der Nachricht einbezogen werden sollen |
-| `apiKey` | string | Nein | API-Schlüssel für die Authentifizierung |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `content` | string | Textantwort-Inhalt vom Agenten |
-| `taskId` | string | Eindeutige Aufgabenkennung |
-| `contextId` | string | Gruppiert zusammenhängende Aufgaben/Nachrichten |
-| `state` | string | Aktueller Lebenszyklus-Status \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `artifacts` | array | Ausgabe-Artefakte der Aufgabe |
-| `history` | array | Gesprächsverlauf \(Message-Array\) |
-
-### `a2a_get_task`
-
-Abfrage des Status einer bestehenden A2A-Aufgabe.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die A2A-Agenten-Endpunkt-URL |
-| `taskId` | string | Ja | Abzufragende Aufgaben-ID |
-| `apiKey` | string | Nein | API-Schlüssel für die Authentifizierung |
-| `historyLength` | number | Nein | Anzahl der einzubeziehenden Verlaufsnachrichten |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `taskId` | string | Eindeutige Aufgabenkennung |
-| `contextId` | string | Gruppiert zusammenhängende Aufgaben/Nachrichten |
-| `state` | string | Aktueller Lebenszyklus-Status \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `artifacts` | array | Ausgabe-Artefakte der Aufgabe |
-| `history` | array | Gesprächsverlauf \(Message-Array\) |
-
-### `a2a_cancel_task`
-
-Abbrechen einer laufenden A2A-Aufgabe.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die A2A-Agenten-Endpunkt-URL |
-| `taskId` | string | Ja | Abzubrechende Aufgaben-ID |
-| `apiKey` | string | Nein | API-Schlüssel für die Authentifizierung |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `cancelled` | boolean | Ob die Stornierung erfolgreich war |
-| `state` | string | Aktueller Lebenszyklus-Status \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-
-### `a2a_get_agent_card`
-
-Ruft die Agent Card (Discovery-Dokument) für einen A2A-Agenten ab.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die Endpunkt-URL des A2A-Agenten |
-| `apiKey` | string | Nein | API-Schlüssel für die Authentifizierung \(falls erforderlich\) |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `name` | string | Anzeigename des Agenten |
-| `description` | string | Zweck/Fähigkeiten des Agenten |
-| `url` | string | Service-Endpunkt-URL |
-| `provider` | object | Details zur Ersteller-Organisation |
-| `capabilities` | object | Feature-Support-Matrix |
-| `skills` | array | Verfügbare Operationen |
-| `version` | string | Vom Agenten unterstützte A2A-Protokollversion |
-| `defaultInputModes` | array | Standard-Eingabe-Inhaltstypen, die vom Agenten akzeptiert werden |
-| `defaultOutputModes` | array | Standard-Ausgabe-Inhaltstypen, die vom Agenten produziert werden |
-
-### `a2a_resubscribe`
-
-Stellt die Verbindung zu einem laufenden A2A-Task-Stream nach einer Verbindungsunterbrechung wieder her.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die Endpunkt-URL des A2A-Agenten |
-| `taskId` | string | Ja | Task-ID, zu der erneut abonniert werden soll |
-| `apiKey` | string | Nein | API-Schlüssel für die Authentifizierung |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `taskId` | string | Eindeutige Aufgabenkennung |
-| `contextId` | string | Gruppiert zusammenhängende Aufgaben/Nachrichten |
-| `state` | string | Aktueller Lebenszyklusstatus \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `isRunning` | boolean | Ob die Aufgabe noch läuft |
-| `artifacts` | array | Ausgabeartefakte der Aufgabe |
-| `history` | array | Gesprächsverlauf \(Message-Array\) |
-
-### `a2a_set_push_notification`
-
-Konfigurieren Sie einen Webhook, um Benachrichtigungen über Aufgabenaktualisierungen zu erhalten.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die A2A-Agent-Endpunkt-URL |
-| `taskId` | string | Ja | Aufgaben-ID, für die Benachrichtigungen konfiguriert werden sollen |
-| `webhookUrl` | string | Ja | HTTPS-Webhook-URL zum Empfang von Benachrichtigungen |
-| `token` | string | Nein | Token zur Webhook-Validierung |
-| `apiKey` | string | Nein | API-Schlüssel zur Authentifizierung |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `url` | string | HTTPS-Webhook-URL für Benachrichtigungen |
-| `token` | string | Authentifizierungstoken zur Webhook-Validierung |
-| `success` | boolean | Ob der Vorgang erfolgreich war |
-
-### `a2a_get_push_notification`
-
-Rufen Sie die Push-Benachrichtigungs-Webhook-Konfiguration für eine Aufgabe ab.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die A2A-Agent-Endpunkt-URL |
-| `taskId` | string | Ja | Aufgaben-ID, für die die Benachrichtigungskonfiguration abgerufen werden soll |
-| `apiKey` | string | Nein | API-Schlüssel zur Authentifizierung |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `token` | string | Authentifizierungstoken für Webhook-Validierung |
-| `exists` | boolean | Ob die Ressource existiert |
-
-### `a2a_delete_push_notification`
-
-Löscht die Push-Benachrichtigungs-Webhook-Konfiguration für eine Aufgabe.
-
-#### Eingabe
-
-| Parameter | Typ | Erforderlich | Beschreibung |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Ja | Die A2A-Agent-Endpunkt-URL |
-| `taskId` | string | Ja | Aufgaben-ID, für die die Benachrichtigungskonfiguration gelöscht werden soll |
-| `pushNotificationConfigId` | string | Nein | Push-Benachrichtigungskonfigurations-ID zum Löschen \(optional - Server kann aus taskId ableiten\) |
-| `apiKey` | string | Nein | API-Schlüssel für Authentifizierung |
-
-#### Ausgabe
-
-| Parameter | Typ | Beschreibung |
-| --------- | ---- | ----------- |
-| `success` | boolean | Ob die Operation erfolgreich war |
diff --git a/apps/docs/content/docs/en/integrations/a2a.mdx b/apps/docs/content/docs/en/integrations/a2a.mdx
new file mode 100644
index 00000000000..f6b03b18784
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/a2a.mdx
@@ -0,0 +1,130 @@
+---
+title: A2A
+description: Interact with external A2A-compatible agents
+---
+
+import { BlockInfoCard } from "@/components/ui/block-info-card"
+
+
+
+{/* MANUAL-CONTENT-START:intro */}
+The A2A (Agent-to-Agent) protocol lets Sim call external AI agents that expose an A2A-compatible endpoint. Use it to connect your workflows to remote agents — LLM-powered bots, microservices, and other AI systems — through a standardized message format.
+
+With the A2A block you can:
+
+- **Send messages to external agents**: Pass prompts, structured data, or files to a remote agent and get its response.
+- **Track and cancel tasks**: Poll the state of a long-running task or request its cancellation.
+- **Discover capabilities**: Fetch an agent's Agent Card to inspect its skills, capabilities, and supported modes.
+
+You need the external agent's endpoint URL and, if it requires authentication, an API key.
+{/* MANUAL-CONTENT-END */}
+
+## Usage Instructions
+
+Use the A2A (Agent-to-Agent) protocol to call external AI agents over the latest A2A specification.
+
+## Tools
+
+### `a2a_send_message`
+
+Send a message to an external A2A agent and return its response.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `agentUrl` | string | Yes | The A2A agent endpoint URL |
+| `message` | string | Yes | The message text to send |
+| `data` | string | No | Optional structured JSON data to attach \(JSON string or object\) |
+| `files` | array | No | Files to attach, uploaded or referenced from a previous block |
+| `taskId` | string | No | Existing task ID to continue |
+| `contextId` | string | No | Conversation context ID to continue |
+| `apiKey` | string | No | API key for authentication \(if required\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Agent response text |
+| `taskId` | string | Task identifier |
+| `contextId` | string | Conversation/context identifier |
+| `state` | string | Task lifecycle state: `submitted`, `working`, `input-required`, `auth-required`, `completed`, `failed`, `canceled`, or `rejected` |
+| `artifacts` | array | Structured task output artifacts |
+
+### `a2a_get_task`
+
+Retrieve the current state and result of an A2A task.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `agentUrl` | string | Yes | The A2A agent endpoint URL |
+| `taskId` | string | Yes | The task ID to retrieve |
+| `historyLength` | number | No | Maximum number of history messages to include |
+| `apiKey` | string | No | API key for authentication \(if required\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Agent response text |
+| `taskId` | string | Task identifier |
+| `contextId` | string | Conversation/context identifier |
+| `state` | string | Task lifecycle state |
+| `artifacts` | array | Structured task output artifacts |
+
+### `a2a_cancel_task`
+
+Request cancellation of an in-progress A2A task.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `agentUrl` | string | Yes | The A2A agent endpoint URL |
+| `taskId` | string | Yes | The task ID to cancel |
+| `apiKey` | string | No | API key for authentication \(if required\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `taskId` | string | Task identifier |
+| `state` | string | Task lifecycle state after cancellation |
+| `canceled` | boolean | Whether the task reached the canceled state |
+
+### `a2a_get_agent_card`
+
+Fetch the Agent Card (discovery document) for an external A2A agent.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `agentUrl` | string | Yes | The A2A agent endpoint URL |
+| `apiKey` | string | No | API key for authentication \(if required\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `name` | string | Agent display name |
+| `description` | string | Agent description |
+| `url` | string | Agent endpoint URL |
+| `version` | string | The agent's own version |
+| `protocolVersion` | string | A2A protocol version the agent exposes |
+| `capabilities` | json | Agent capability flags |
+| `skills` | array | Skills the agent can perform |
+| `defaultInputModes` | array | Default accepted input media types |
+| `defaultOutputModes` | array | Default produced output media types |
+
+## Notes
+
+- Category: `blocks`
+- Type: `a2a`
+- Send Message blocks until the agent reaches a terminal (`completed`, `failed`, `canceled`, `rejected`) or interrupted (`input-required`, `auth-required`) state. Use Get Task to poll a task you continue later, and branch on `state`.
+- Task IDs are scoped to the external agent, not to Sim. Anyone who knows an agent URL and task ID can read or cancel that task unless the agent enforces its own authentication — set an API key for agents that require one.
diff --git a/apps/docs/content/docs/en/integrations/airtable.mdx b/apps/docs/content/docs/en/integrations/airtable.mdx
index e501d9b6498..63314699c05 100644
--- a/apps/docs/content/docs/en/integrations/airtable.mdx
+++ b/apps/docs/content/docs/en/integrations/airtable.mdx
@@ -26,7 +26,7 @@ In Sim, the Airtable integration enables your agents to interact with your Airta
## Usage Instructions
-Integrates Airtable into the workflow. Can list bases, list tables (with schema), and create, get, list, or update records. Can also be used in trigger mode to trigger a workflow when an update is made to an Airtable table.
+Integrates Airtable into the workflow. Can list bases, list tables (with schema), and create, get, list, update, upsert, or delete records. Can also be used in trigger mode to trigger a workflow when an update is made to an Airtable table.
@@ -203,6 +203,58 @@ Update multiple existing records in an Airtable table
| ↳ `recordCount` | number | Number of records updated |
| ↳ `updatedRecordIds` | array | List of updated record IDs |
+### `airtable_upsert_records`
+
+Update existing records or create new ones in an Airtable table, matching on the specified merge fields
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) |
+| `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name |
+| `records` | json | Yes | Array of records to upsert, each with a `fields` object |
+| `fieldsToMergeOn` | json | Yes | Array of field names used to match existing records \(max 3\). A record is updated when all merge fields match, otherwise it is created. Example: \["Name"\] |
+| `typecast` | boolean | No | When true, Airtable automatically converts string values to the field type |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `records` | array | Array of upserted Airtable records |
+| ↳ `id` | string | Record ID |
+| ↳ `createdTime` | string | Record creation timestamp |
+| ↳ `fields` | json | Record field values |
+| `createdRecords` | array | IDs of records that were created |
+| `updatedRecords` | array | IDs of records that were updated |
+| `metadata` | json | Operation metadata |
+| ↳ `recordCount` | number | Total number of records returned |
+| ↳ `createdCount` | number | Number of records created |
+| ↳ `updatedCount` | number | Number of records updated |
+
+### `airtable_delete_records`
+
+Delete one or more records from an Airtable table by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) |
+| `tableId` | string | Yes | Table ID \(starts with "tbl"\) or table name |
+| `recordIds` | json | Yes | Array of record IDs to delete \(each starts with "rec", e.g., \["recXXXXXXXXXXXXXX"\]\). Pass a single-element array to delete one record. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `records` | array | Array of deleted Airtable records |
+| ↳ `id` | string | Record ID |
+| ↳ `deleted` | boolean | Whether the record was deleted |
+| `metadata` | json | Operation metadata |
+| ↳ `recordCount` | number | Number of records deleted |
+| ↳ `deletedRecordIds` | array | List of deleted record IDs |
+
### `airtable_get_base_schema`
Get the schema of all tables, fields, and views in an Airtable base
diff --git a/apps/docs/content/docs/en/integrations/clerk.mdx b/apps/docs/content/docs/en/integrations/clerk.mdx
index f912d0e1f4e..7a9df1a41fd 100644
--- a/apps/docs/content/docs/en/integrations/clerk.mdx
+++ b/apps/docs/content/docs/en/integrations/clerk.mdx
@@ -440,3 +440,207 @@ Revoke a session to immediately invalidate it
| `success` | boolean | Operation success status |
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### Clerk Organization Created
+
+Trigger workflow when a Clerk organization is created
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `organizationId` | string | Clerk organization ID \(data.id\) |
+| `name` | string | Organization name \(data.name\) |
+| `slug` | string | Organization slug \(data.slug\) |
+| `createdBy` | string | User ID of the creator \(data.created_by\) |
+| `membersCount` | number | Number of members \(data.members_count\) |
+| `maxAllowedMemberships` | number | Maximum allowed memberships \(data.max_allowed_memberships\) |
+| `createdAt` | number | Organization creation timestamp \(data.created_at\) |
+
+
+---
+
+### Clerk Organization Membership Created
+
+Trigger workflow when a Clerk organization membership is created
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `membershipId` | string | Membership ID \(data.id\) |
+| `role` | string | Membership role, e.g. org:admin \(data.role\) |
+| `organizationId` | string | Organization ID \(data.organization.id\) |
+| `userId` | string | User ID of the member \(data.public_user_data.user_id\) |
+| `createdAt` | number | Membership creation timestamp \(data.created_at\) |
+
+
+---
+
+### Clerk Session Created
+
+Trigger workflow when a Clerk session is created
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `sessionId` | string | Clerk session ID \(data.id\) |
+| `userId` | string | User the session belongs to \(data.user_id\) |
+| `clientId` | string | Client ID for the session \(data.client_id\) |
+| `status` | string | Session status \(data.status\) |
+| `createdAt` | number | Session creation timestamp \(data.created_at\) |
+
+
+---
+
+### Clerk User Created
+
+Trigger workflow when a Clerk user is created
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `userId` | string | Clerk user ID \(data.id\) |
+| `firstName` | string | User's first name |
+| `lastName` | string | User's last name |
+| `username` | string | User's username |
+| `imageUrl` | string | Profile image URL |
+| `primaryEmailAddressId` | string | Primary email address ID |
+| `emailAddresses` | json | Array of email address objects |
+| `phoneNumbers` | json | Array of phone number objects |
+| `externalId` | string | External system ID linked to the user |
+| `createdAt` | number | User creation timestamp \(data.created_at\) |
+| `updatedAt` | number | User last update timestamp \(data.updated_at\) |
+
+
+---
+
+### Clerk User Deleted
+
+Trigger workflow when a Clerk user is deleted
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `userId` | string | Deleted Clerk user ID \(data.id\) |
+| `deleted` | boolean | Whether the user was deleted \(data.deleted\) |
+
+
+---
+
+### Clerk User Updated
+
+Trigger workflow when a Clerk user is updated
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `userId` | string | Clerk user ID \(data.id\) |
+| `firstName` | string | User's first name |
+| `lastName` | string | User's last name |
+| `username` | string | User's username |
+| `imageUrl` | string | Profile image URL |
+| `primaryEmailAddressId` | string | Primary email address ID |
+| `emailAddresses` | json | Array of email address objects |
+| `phoneNumbers` | json | Array of phone number objects |
+| `externalId` | string | External system ID linked to the user |
+| `createdAt` | number | User creation timestamp \(data.created_at\) |
+| `updatedAt` | number | User last update timestamp \(data.updated_at\) |
+
+
+---
+
+### Clerk Webhook
+
+Trigger workflow on any Clerk webhook event
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+
diff --git a/apps/docs/content/docs/en/integrations/elevenlabs.mdx b/apps/docs/content/docs/en/integrations/elevenlabs.mdx
index 0099b3a1fd8..ca8fef8cd3a 100644
--- a/apps/docs/content/docs/en/integrations/elevenlabs.mdx
+++ b/apps/docs/content/docs/en/integrations/elevenlabs.mdx
@@ -1,6 +1,6 @@
---
title: ElevenLabs
-description: Convert text to speech with ElevenLabs
+description: Generate and transform audio with ElevenLabs
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -27,7 +27,7 @@ In Sim, the ElevenLabs integration enables your agents to convert text to lifeli
## Usage Instructions
-Integrate ElevenLabs into the workflow. Can convert text to speech.
+Integrate ElevenLabs into the workflow. Convert text to speech, generate sound effects, transform voices, isolate audio, and manage voices, models, and account settings.
@@ -55,4 +55,214 @@ Convert text to speech using ElevenLabs voices
| `audioUrl` | string | The URL of the generated audio |
| `audioFile` | file | The generated audio file |
+### `elevenlabs_sound_effects`
+
+Generate a sound effect from a text prompt using ElevenLabs
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+| `text` | string | Yes | The prompt describing the sound effect \(e.g., "thunder rumbling in the distance"\) |
+| `modelId` | string | No | The model to use \(defaults to eleven_text_to_sound_v2\) |
+| `durationSeconds` | number | No | Length of the sound in seconds \(0.5-30\). Omit to auto-determine |
+| `promptInfluence` | number | No | How closely to follow the prompt from 0.0 to 1.0 \(default 0.3\) |
+| `loop` | boolean | No | Whether to generate a seamlessly looping sound effect \(default false\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `audioUrl` | string | URL of the generated sound effect |
+| `audioFile` | file | The generated sound effect file |
+
+### `elevenlabs_speech_to_speech`
+
+Convert audio into a chosen ElevenLabs voice while preserving content and emotion
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+| `voiceId` | string | Yes | The ID of the target voice to convert the audio into |
+| `audioFile` | file | Yes | The source audio file to convert \(e.g., MP3, WAV, M4A\) |
+| `modelId` | string | No | The model to use \(defaults to eleven_english_sts_v2\) |
+| `removeBackgroundNoise` | boolean | No | Whether to isolate the voice and remove background noise \(default false\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `audioUrl` | string | URL of the converted audio |
+| `audioFile` | file | The converted audio file |
+
+### `elevenlabs_audio_isolation`
+
+Remove background noise from an audio file, isolating the speech using ElevenLabs
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+| `audioFile` | file | Yes | The audio file to isolate speech from \(e.g., MP3, WAV, M4A\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `audioUrl` | string | URL of the isolated audio |
+| `audioFile` | file | The isolated audio file |
+
+### `elevenlabs_list_voices`
+
+List the voices available in your ElevenLabs account
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+| `search` | string | No | Search term to filter voices by name, description, labels, or category |
+| `category` | string | No | Filter by category: premade, cloned, generated, or professional |
+| `pageSize` | number | No | Number of voices to return \(1-100, default 10\) |
+| `nextPageToken` | string | No | Page token from a previous response to fetch the next page of voices |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `voices` | array | List of voices |
+| ↳ `voiceId` | string | Unique voice identifier |
+| ↳ `name` | string | Voice name |
+| ↳ `category` | string | Voice category |
+| ↳ `description` | string | Voice description |
+| ↳ `labels` | json | Voice labels \(accent, gender, age, use case\) |
+| ↳ `previewUrl` | string | URL to a preview audio sample |
+| ↳ `settings` | json | Default voice settings |
+| `totalCount` | number | Total number of matching voices |
+| `hasMore` | boolean | Whether more voices are available |
+| `nextPageToken` | string | Token to fetch the next page |
+
+### `elevenlabs_get_voice`
+
+Get metadata and settings for a specific ElevenLabs voice
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+| `voiceId` | string | Yes | The ID of the voice to retrieve \(e.g., "21m00Tcm4TlvDq8ikWAM"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `voiceId` | string | Unique voice identifier |
+| `name` | string | Voice name |
+| `category` | string | Voice category |
+| `description` | string | Voice description |
+| `labels` | json | Voice labels \(accent, gender, age, use case\) |
+| `previewUrl` | string | URL to a preview audio sample |
+| `settings` | json | Default voice settings |
+| `availableForTiers` | array | Subscription tiers the voice is available on |
+| `highQualityBaseModelIds` | array | Model IDs that support high-quality output for this voice |
+| `isOwner` | boolean | Whether the current user owns this voice |
+
+### `elevenlabs_get_voice_settings`
+
+Get the configured settings for a specific ElevenLabs voice
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+| `voiceId` | string | Yes | The ID of the voice whose settings to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `stability` | number | Voice stability \(0.0-1.0\) |
+| `similarityBoost` | number | Similarity boost \(0.0-1.0\) |
+| `style` | number | Style exaggeration \(0.0-1.0\) |
+| `useSpeakerBoost` | boolean | Whether speaker boost is enabled |
+| `speed` | number | Speech speed \(1.0 = normal\) |
+
+### `elevenlabs_edit_voice_settings`
+
+Update the settings for a specific ElevenLabs voice
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+| `voiceId` | string | Yes | The ID of the voice to update |
+| `stability` | number | No | Voice stability from 0.0 to 1.0 \(default 0.5\) |
+| `similarityBoost` | number | No | Similarity boost from 0.0 to 1.0 \(default 0.75\) |
+| `style` | number | No | Style exaggeration from 0.0 to 1.0 \(default 0\) |
+| `useSpeakerBoost` | boolean | No | Whether to enhance similarity to the original speaker \(default true\) |
+| `speed` | number | No | Speech speed where 1.0 is normal \(default 1.0\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | string | Request outcome \("ok" on success\) |
+
+### `elevenlabs_list_models`
+
+List the models available in ElevenLabs
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `models` | array | List of available models |
+| ↳ `modelId` | string | Unique model identifier |
+| ↳ `name` | string | Model name |
+| ↳ `description` | string | Model description |
+| ↳ `canDoTextToSpeech` | boolean | Supports text-to-speech |
+| ↳ `canDoVoiceConversion` | boolean | Supports voice conversion |
+| ↳ `canUseStyle` | boolean | Supports the style parameter |
+| ↳ `canUseSpeakerBoost` | boolean | Supports speaker boost |
+| ↳ `languages` | array | Languages supported by the model |
+| ↳ `languageId` | string | Language code |
+| ↳ `name` | string | Language name |
+
+### `elevenlabs_get_user`
+
+Get account and subscription information for the ElevenLabs user
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Your ElevenLabs API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `userId` | string | Unique user identifier |
+| `isNewUser` | boolean | Whether the user is new |
+| `subscription` | object | Subscription and usage details |
+| ↳ `tier` | string | Subscription tier |
+| ↳ `characterCount` | number | Characters used this period |
+| ↳ `characterLimit` | number | Character quota for this period |
+| ↳ `canExtendCharacterLimit` | boolean | Whether the character limit can be extended |
+| ↳ `status` | string | Subscription status |
+| ↳ `nextCharacterCountResetUnix` | number | Unix timestamp when the character count resets |
+
diff --git a/apps/docs/content/docs/en/integrations/firecrawl.mdx b/apps/docs/content/docs/en/integrations/firecrawl.mdx
index adde3194056..64be2dac640 100644
--- a/apps/docs/content/docs/en/integrations/firecrawl.mdx
+++ b/apps/docs/content/docs/en/integrations/firecrawl.mdx
@@ -78,6 +78,81 @@ Extract structured content from web pages with comprehensive metadata support. C
| ↳ `ogSiteName` | string | Open Graph site name |
| ↳ `error` | string | Error message if scrape failed |
+### `firecrawl_batch_scrape`
+
+Scrape multiple URLs in a single batch job and retrieve structured content from each page.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `urls` | json | Yes | Array of URLs to scrape \(e.g., \["https://example.com/page1", "https://example.com/page2"\]\) |
+| `formats` | json | No | Output formats for scraped content \(e.g., \["markdown"\], \["markdown", "html"\]\) |
+| `onlyMainContent` | boolean | No | Extract only main content from pages |
+| `maxConcurrency` | number | No | Maximum number of concurrent scrapes |
+| `ignoreInvalidURLs` | boolean | No | Skip invalid URLs instead of failing the batch \(default: true\) |
+| `scrapeOptions` | json | No | Advanced scraping configuration options |
+| `zeroDataRetention` | boolean | No | Enable zero data retention |
+| `apiKey` | string | Yes | Firecrawl API key |
+| `pricing` | custom | No | No description |
+| `rateLimit` | string | No | No description |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `pages` | array | Array of scraped pages with their content and metadata |
+| ↳ `markdown` | string | Page content in markdown format |
+| ↳ `html` | string | Processed HTML content of the page |
+| ↳ `rawHtml` | string | Unprocessed raw HTML content |
+| ↳ `links` | array | Array of links found on the page |
+| ↳ `screenshot` | string | Screenshot URL \(expires after 24 hours\) |
+| ↳ `metadata` | object | Page metadata from crawl operation |
+| ↳ `title` | string | Page title |
+| ↳ `description` | string | Page meta description |
+| ↳ `language` | string | Page language code |
+| ↳ `sourceURL` | string | Original source URL |
+| ↳ `statusCode` | number | HTTP status code |
+| ↳ `ogLocaleAlternate` | array | Alternate locale versions |
+| `total` | number | Total number of pages attempted |
+| `completed` | number | Number of pages successfully scraped |
+| `invalidURLs` | array | URLs that were skipped because they were invalid |
+
+### `firecrawl_batch_scrape_status`
+
+Check the status and retrieve results of a previously started Firecrawl batch scrape job by its job ID.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `jobId` | string | Yes | The ID of the batch scrape job to check |
+| `apiKey` | string | Yes | Firecrawl API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | string | Current batch scrape status \(scraping, completed, or failed\) |
+| `total` | number | Total number of pages attempted |
+| `completed` | number | Number of pages successfully scraped |
+| `creditsUsed` | number | Credits consumed by the batch scrape |
+| `expiresAt` | string | ISO timestamp when the batch scrape results expire |
+| `next` | string | URL to retrieve the next page of results when present |
+| `pages` | array | Array of scraped pages with their content and metadata |
+| ↳ `markdown` | string | Page content in markdown format |
+| ↳ `html` | string | Processed HTML content of the page |
+| ↳ `rawHtml` | string | Unprocessed raw HTML content |
+| ↳ `links` | array | Array of links found on the page |
+| ↳ `screenshot` | string | Screenshot URL \(expires after 24 hours\) |
+| ↳ `metadata` | object | Page metadata from crawl operation |
+| ↳ `title` | string | Page title |
+| ↳ `description` | string | Page meta description |
+| ↳ `language` | string | Page language code |
+| ↳ `sourceURL` | string | Original source URL |
+| ↳ `statusCode` | number | HTTP status code |
+| ↳ `ogLocaleAlternate` | array | Alternate locale versions |
+
### `firecrawl_search`
Search for information on the web using Firecrawl
@@ -149,6 +224,58 @@ Crawl entire websites and extract structured content from all accessible pages
| ↳ `ogLocaleAlternate` | array | Alternate locale versions |
| `total` | number | Total number of pages found during crawl |
+### `firecrawl_crawl_status`
+
+Check the status and retrieve results of a previously started Firecrawl crawl job by its job ID.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `jobId` | string | Yes | The ID of the crawl job to check |
+| `apiKey` | string | Yes | Firecrawl API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | string | Current crawl status \(scraping, completed, or failed\) |
+| `total` | number | Total number of pages attempted |
+| `completed` | number | Number of pages successfully crawled |
+| `creditsUsed` | number | Credits consumed by the crawl |
+| `expiresAt` | string | ISO timestamp when the crawl results expire |
+| `next` | string | URL to retrieve the next page of results when present |
+| `pages` | array | Array of crawled pages with their content and metadata |
+| ↳ `markdown` | string | Page content in markdown format |
+| ↳ `html` | string | Processed HTML content of the page |
+| ↳ `rawHtml` | string | Unprocessed raw HTML content |
+| ↳ `links` | array | Array of links found on the page |
+| ↳ `screenshot` | string | Screenshot URL \(expires after 24 hours\) |
+| ↳ `metadata` | object | Page metadata from crawl operation |
+| ↳ `title` | string | Page title |
+| ↳ `description` | string | Page meta description |
+| ↳ `language` | string | Page language code |
+| ↳ `sourceURL` | string | Original source URL |
+| ↳ `statusCode` | number | HTTP status code |
+| ↳ `ogLocaleAlternate` | array | Alternate locale versions |
+
+### `firecrawl_cancel_crawl`
+
+Cancel an in-progress Firecrawl crawl job by its job ID.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `jobId` | string | Yes | The ID of the crawl job to cancel |
+| `apiKey` | string | Yes | Firecrawl API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | string | Status of the cancelled crawl job \(e.g., "cancelled"\) |
+
### `firecrawl_map`
Get a complete list of URLs from any website quickly and reliably. Useful for discovering all pages on a site without crawling them.
@@ -204,6 +331,27 @@ Extract structured data from entire webpages using natural language prompts and
| `success` | boolean | Whether the extraction operation was successful |
| `data` | object | Extracted structured data according to the schema or prompt |
+### `firecrawl_extract_status`
+
+Check the status and retrieve results of a previously started Firecrawl extract job by its job ID.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `jobId` | string | Yes | The ID of the extract job to check |
+| `apiKey` | string | Yes | Firecrawl API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | string | Current extract status \(processing, completed, failed, or cancelled\) |
+| `data` | json | Extracted structured data according to the schema or prompt |
+| `expiresAt` | string | ISO timestamp when the extract results expire |
+| `creditsUsed` | number | Number of credits used by the extract job |
+| `tokensUsed` | number | Number of tokens used by the extract job |
+
### `firecrawl_agent`
Autonomous web data extraction agent. Searches and gathers information based on natural language prompts without requiring specific URLs.
@@ -274,4 +422,23 @@ Parse uploaded documents (PDF, DOCX, HTML, etc.) into clean markdown using Firec
| ↳ `error` | string | Error message if parse failed |
| `warning` | string | Warning message from the parse operation |
+### `firecrawl_credit_usage`
+
+Retrieve the remaining and allocated Firecrawl credits for the team.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Firecrawl API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `remainingCredits` | number | Number of credits remaining for the team |
+| `planCredits` | number | Credits allocated in the current plan |
+| `billingPeriodStart` | string | Start of the current billing period |
+| `billingPeriodEnd` | string | End of the current billing period |
+
diff --git a/apps/docs/content/docs/en/integrations/google_docs.mdx b/apps/docs/content/docs/en/integrations/google_docs.mdx
index e666fcc2ad5..9dc591ccb0a 100644
--- a/apps/docs/content/docs/en/integrations/google_docs.mdx
+++ b/apps/docs/content/docs/en/integrations/google_docs.mdx
@@ -1,6 +1,6 @@
---
title: Google Docs
-description: Read, write, and create documents
+description: Read, write, create, and edit documents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -27,7 +27,7 @@ In Sim, the Google Docs integration allows your agents to read document content,
## Usage Instructions
-Integrate Google Docs into the workflow. Can read, write, and create documents.
+Integrate Google Docs into the workflow. Read, write, and create documents, insert text, tables, images, and page breaks, find and replace text, and apply text styling.
@@ -100,4 +100,149 @@ Create a new Google Docs document
| ↳ `mimeType` | string | Document MIME type |
| ↳ `url` | string | Document URL |
+### `google_docs_insert_text`
+
+Insert text at a specific index in a Google Docs document. When no index is provided, text is appended to the end of the document. Text is inserted literally; Markdown is not interpreted.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `documentId` | string | Yes | The ID of the document to insert text into |
+| `text` | string | Yes | The text to insert |
+| `index` | number | No | The 1-based character index at which to insert the text. When omitted, text is appended to the end of the document. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `updatedContent` | boolean | Indicates if text was inserted successfully |
+| `metadata` | json | Updated document metadata including ID, title, and URL |
+| ↳ `documentId` | string | Google Docs document ID |
+| ↳ `title` | string | Document title |
+| ↳ `mimeType` | string | Document MIME type |
+| ↳ `url` | string | Document URL |
+
+### `google_docs_replace_text`
+
+Replace all occurrences of a search string with new text across a Google Docs document.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `documentId` | string | Yes | The ID of the document to update |
+| `searchText` | string | Yes | The text to find |
+| `replaceText` | string | No | The text to replace matches with. Use an empty string to delete matches. |
+| `matchCase` | boolean | No | Whether the search should be case sensitive. Defaults to false. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `occurrencesChanged` | number | The number of occurrences that were replaced |
+| `metadata` | json | Updated document metadata including ID, title, and URL |
+| ↳ `documentId` | string | Google Docs document ID |
+| ↳ `title` | string | Document title |
+| ↳ `mimeType` | string | Document MIME type |
+| ↳ `url` | string | Document URL |
+
+### `google_docs_insert_table`
+
+Insert an empty table with the given number of rows and columns into a Google Docs document. When no index is provided, the table is appended to the end of the document.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `documentId` | string | Yes | The ID of the document to insert the table into |
+| `rows` | number | Yes | The number of rows in the table |
+| `columns` | number | Yes | The number of columns in the table |
+| `index` | number | No | The 1-based character index at which to insert the table. When omitted, the table is appended to the end of the document. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `updatedContent` | boolean | Indicates if the table was inserted successfully |
+| `metadata` | json | Updated document metadata including ID, title, and URL |
+| ↳ `documentId` | string | Google Docs document ID |
+| ↳ `title` | string | Document title |
+| ↳ `mimeType` | string | Document MIME type |
+| ↳ `url` | string | Document URL |
+
+### `google_docs_insert_image`
+
+Insert an inline image from a public URL into a Google Docs document. The image must be publicly accessible and under 50 MB. When no index is provided, the image is appended to the end of the document.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `documentId` | string | Yes | The ID of the document to insert the image into |
+| `imageUrl` | string | Yes | The publicly accessible URL of the image to insert |
+| `index` | number | No | The 1-based character index at which to insert the image. When omitted, the image is appended to the end of the document. |
+| `width` | number | No | Optional image width in points \(PT\) |
+| `height` | number | No | Optional image height in points \(PT\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `objectId` | string | The ID of the inserted inline image object |
+| `metadata` | json | Updated document metadata including ID, title, and URL |
+| ↳ `documentId` | string | Google Docs document ID |
+| ↳ `title` | string | Document title |
+| ↳ `mimeType` | string | Document MIME type |
+| ↳ `url` | string | Document URL |
+
+### `google_docs_insert_page_break`
+
+Insert a page break into a Google Docs document. When no index is provided, the page break is appended to the end of the document.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `documentId` | string | Yes | The ID of the document to insert the page break into |
+| `index` | number | No | The 1-based character index at which to insert the page break. When omitted, the page break is appended to the end of the document. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `updatedContent` | boolean | Indicates if the page break was inserted successfully |
+| `metadata` | json | Updated document metadata including ID, title, and URL |
+| ↳ `documentId` | string | Google Docs document ID |
+| ↳ `title` | string | Document title |
+| ↳ `mimeType` | string | Document MIME type |
+| ↳ `url` | string | Document URL |
+
+### `google_docs_update_text_style`
+
+Apply bold, italic, underline, and/or font size to a range of text in a Google Docs document, identified by its start and end character index.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `documentId` | string | Yes | The ID of the document to update |
+| `startIndex` | number | Yes | The 1-based start character index of the range to style \(inclusive\) |
+| `endIndex` | number | Yes | The end character index of the range to style \(exclusive\) |
+| `bold` | boolean | No | Whether to make the text bold |
+| `italic` | boolean | No | Whether to make the text italic |
+| `underline` | boolean | No | Whether to underline the text |
+| `fontSize` | number | No | The font size to apply, in points \(PT\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `updatedContent` | boolean | Indicates if the text style was applied successfully |
+| `metadata` | json | Updated document metadata including ID, title, and URL |
+| ↳ `documentId` | string | Google Docs document ID |
+| ↳ `title` | string | Document title |
+| ↳ `mimeType` | string | Document MIME type |
+| ↳ `url` | string | Document URL |
+
diff --git a/apps/docs/content/docs/en/integrations/google_drive.mdx b/apps/docs/content/docs/en/integrations/google_drive.mdx
index 5b2d22b43f8..139bd86ab2e 100644
--- a/apps/docs/content/docs/en/integrations/google_drive.mdx
+++ b/apps/docs/content/docs/en/integrations/google_drive.mdx
@@ -694,6 +694,163 @@ List all permissions (who has access) for a file in Google Drive
| ↳ `permissionDetails` | json | Details about inherited permissions |
| `nextPageToken` | string | Token for fetching the next page of permissions |
+### `google_drive_export`
+
+Export a Google Workspace file (Docs, Sheets, Slides, Drawings) to a chosen format such as PDF, DOCX, XLSX, or CSV
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `fileId` | string | Yes | The ID of the Google Workspace file to export |
+| `mimeType` | string | Yes | The target MIME type to export to \(e.g. application/pdf, text/csv\) |
+| `fileName` | string | No | Optional filename override for the exported file |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `file` | file | Exported file stored in execution files |
+| `exportedMimeType` | string | The MIME type the file was exported to |
+
+### `google_drive_list_revisions`
+
+List the revision history of a file in Google Drive
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `fileId` | string | Yes | The ID of the file to list revisions for |
+| `pageSize` | number | No | Maximum number of revisions to return \(1-1000, default 200\) |
+| `pageToken` | string | No | The page token to use for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `revisions` | array | List of revisions for the file \(most recent last\) |
+| ↳ `id` | string | Revision ID |
+| ↳ `mimeType` | string | MIME type of the revision |
+| ↳ `modifiedTime` | string | When this revision was created |
+| ↳ `keepForever` | boolean | Whether this revision is preserved forever |
+| ↳ `published` | boolean | Whether this revision is published |
+| ↳ `publishedLink` | string | Public link to the published revision |
+| ↳ `lastModifyingUser` | json | User who created this revision |
+| ↳ `originalFilename` | string | Original filename for binary revisions |
+| ↳ `md5Checksum` | string | MD5 checksum for binary revisions |
+| ↳ `size` | string | Size of the revision in bytes |
+| ↳ `exportLinks` | json | Export format links for the revision |
+| `nextPageToken` | string | Token for fetching the next page of revisions |
+
+### `google_drive_get_revision`
+
+Get metadata for a specific revision of a file in Google Drive
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `fileId` | string | Yes | The ID of the file the revision belongs to |
+| `revisionId` | string | Yes | The ID of the revision to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `revision` | json | The revision metadata |
+| ↳ `id` | string | Revision ID |
+| ↳ `mimeType` | string | MIME type of the revision |
+| ↳ `modifiedTime` | string | When this revision was created |
+| ↳ `keepForever` | boolean | Whether this revision is preserved forever |
+| ↳ `published` | boolean | Whether this revision is published |
+| ↳ `publishedLink` | string | Public link to the published revision |
+| ↳ `lastModifyingUser` | json | User who created this revision |
+| ↳ `originalFilename` | string | Original filename for binary revisions |
+| ↳ `md5Checksum` | string | MD5 checksum for binary revisions |
+| ↳ `size` | string | Size of the revision in bytes |
+| ↳ `exportLinks` | json | Export format links for the revision |
+
+### `google_drive_list_comments`
+
+List comments on a file in Google Drive
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `fileId` | string | Yes | The ID of the file to list comments for |
+| `includeDeleted` | boolean | No | Whether to include deleted comments \(their content is stripped\) |
+| `pageSize` | number | No | Maximum number of comments to return \(1-100, default 20\) |
+| `startModifiedTime` | string | No | Only return comments modified after this RFC 3339 timestamp |
+| `pageToken` | string | No | The page token to use for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `comments` | array | List of comments on the file |
+| ↳ `id` | string | Comment ID |
+| ↳ `content` | string | Plain text content of the comment |
+| ↳ `htmlContent` | string | HTML-formatted content of the comment |
+| ↳ `author` | json | User who authored the comment |
+| ↳ `createdTime` | string | When the comment was created |
+| ↳ `modifiedTime` | string | When the comment was last modified |
+| ↳ `resolved` | boolean | Whether the comment has been resolved |
+| ↳ `deleted` | boolean | Whether the comment has been deleted |
+| ↳ `anchor` | string | Region of the document the comment refers to |
+| ↳ `quotedFileContent` | json | The file content the comment quotes |
+| ↳ `replies` | json | Threaded replies to the comment |
+| `nextPageToken` | string | Token for fetching the next page of comments |
+
+### `google_drive_create_comment`
+
+Add a comment to a file in Google Drive
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `fileId` | string | Yes | The ID of the file to comment on |
+| `content` | string | Yes | The plain text content of the comment |
+| `anchor` | string | No | A region of the document the comment refers to \(JSON anchor string\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `comment` | json | The created comment |
+| ↳ `id` | string | Comment ID |
+| ↳ `content` | string | Plain text content of the comment |
+| ↳ `htmlContent` | string | HTML-formatted content of the comment |
+| ↳ `author` | json | User who authored the comment |
+| ↳ `createdTime` | string | When the comment was created |
+| ↳ `modifiedTime` | string | When the comment was last modified |
+| ↳ `resolved` | boolean | Whether the comment has been resolved |
+| ↳ `deleted` | boolean | Whether the comment has been deleted |
+| ↳ `anchor` | string | Region of the document the comment refers to |
+| ↳ `quotedFileContent` | json | The file content the comment quotes |
+| ↳ `replies` | json | Threaded replies to the comment |
+
+### `google_drive_delete_comment`
+
+Delete a comment from a file in Google Drive
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `fileId` | string | Yes | The ID of the file the comment belongs to |
+| `commentId` | string | Yes | The ID of the comment to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the comment was successfully deleted |
+| `fileId` | string | The ID of the file |
+| `commentId` | string | The ID of the deleted comment |
+
### `google_drive_get_about`
Get information about the user and their Google Drive (storage quota, capabilities)
diff --git a/apps/docs/content/docs/en/integrations/incidentio.mdx b/apps/docs/content/docs/en/integrations/incidentio.mdx
index 919f8cf00f0..da6c2dce200 100644
--- a/apps/docs/content/docs/en/integrations/incidentio.mdx
+++ b/apps/docs/content/docs/en/integrations/incidentio.mdx
@@ -1388,3 +1388,141 @@ Delete an escalation path in incident.io
| `message` | string | Success message |
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### incident.io Alert Created
+
+Trigger workflow when an alert is created in incident.io
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | The signing secret from your incident.io webhook endpoint. Used to verify events. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `event_type` | string | incident.io event type \(e.g., public_incident.incident_created_v2\). Top-level `event_type` field. |
+| `payload` | json | Full raw webhook body as delivered by incident.io \(the entire Svix envelope\). |
+| `alert` | json | The full alert object from the webhook payload. |
+| `alert_id` | string | Unique alert ID. |
+| `title` | string | Alert title. |
+| `description` | string | Alert description, when set. |
+| `status` | string | Alert status \(e.g., firing, resolved\). |
+| `alert_source_id` | string | ID of the alert source that raised the alert. |
+| `deduplication_key` | string | Deduplication key for the alert, when set. |
+| `source_url` | string | URL to the alert in the originating system, when set. |
+| `created_at` | string | ISO 8601 timestamp when the alert was created. |
+| `updated_at` | string | ISO 8601 timestamp when the alert was last updated. |
+| `resolved_at` | string | ISO 8601 timestamp when the alert was resolved, when applicable. |
+
+
+---
+
+### incident.io Incident Created
+
+Trigger workflow when an incident is created in incident.io
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | The signing secret from your incident.io webhook endpoint. Used to verify events. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `event_type` | string | incident.io event type \(e.g., public_incident.incident_created_v2\). Top-level `event_type` field. |
+| `payload` | json | Full raw webhook body as delivered by incident.io \(the entire Svix envelope\). |
+| `incident` | json | The full incident object from the webhook payload. |
+| `incident_id` | string | Unique incident ID \(e.g., 01FDAG4SAP5TYPT98WGR2N7W91\). |
+| `name` | string | Incident name. |
+| `reference` | string | Human-readable incident reference \(e.g., INC-123\). |
+| `summary` | string | Incident summary, when set. |
+| `incident_status` | json | The incident status object \(id, name, category, rank\). |
+| `severity` | json | The incident severity object \(id, name, rank\), when set. |
+| `mode` | string | Incident mode \(standard, retrospective, test, tutorial, stream\). |
+| `visibility` | string | Incident visibility \(public or private\). |
+| `permalink` | string | Link to the incident in incident.io, when present. |
+| `created_at` | string | ISO 8601 timestamp when the incident was created. |
+| `updated_at` | string | ISO 8601 timestamp when the incident was last updated. |
+| `new_status` | json | New status object \(status-updated events only; null otherwise\). |
+| `previous_status` | json | Previous status object \(status-updated events only; null otherwise\). |
+| `update_message` | string | Update message accompanying a status change \(status-updated events only; null otherwise\). |
+
+
+---
+
+### incident.io Incident Status Updated
+
+Trigger workflow when an incident
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | The signing secret from your incident.io webhook endpoint. Used to verify events. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `event_type` | string | incident.io event type \(e.g., public_incident.incident_created_v2\). Top-level `event_type` field. |
+| `payload` | json | Full raw webhook body as delivered by incident.io \(the entire Svix envelope\). |
+| `incident` | json | The full incident object from the webhook payload. |
+| `incident_id` | string | Unique incident ID \(e.g., 01FDAG4SAP5TYPT98WGR2N7W91\). |
+| `name` | string | Incident name. |
+| `reference` | string | Human-readable incident reference \(e.g., INC-123\). |
+| `summary` | string | Incident summary, when set. |
+| `incident_status` | json | The incident status object \(id, name, category, rank\). |
+| `severity` | json | The incident severity object \(id, name, rank\), when set. |
+| `mode` | string | Incident mode \(standard, retrospective, test, tutorial, stream\). |
+| `visibility` | string | Incident visibility \(public or private\). |
+| `permalink` | string | Link to the incident in incident.io, when present. |
+| `created_at` | string | ISO 8601 timestamp when the incident was created. |
+| `updated_at` | string | ISO 8601 timestamp when the incident was last updated. |
+| `new_status` | json | New status object \(status-updated events only; null otherwise\). |
+| `previous_status` | json | Previous status object \(status-updated events only; null otherwise\). |
+| `update_message` | string | Update message accompanying a status change \(status-updated events only; null otherwise\). |
+
+
+---
+
+### incident.io Incident Updated
+
+Trigger workflow when an incident is updated in incident.io
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | The signing secret from your incident.io webhook endpoint. Used to verify events. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `event_type` | string | incident.io event type \(e.g., public_incident.incident_created_v2\). Top-level `event_type` field. |
+| `payload` | json | Full raw webhook body as delivered by incident.io \(the entire Svix envelope\). |
+| `incident` | json | The full incident object from the webhook payload. |
+| `incident_id` | string | Unique incident ID \(e.g., 01FDAG4SAP5TYPT98WGR2N7W91\). |
+| `name` | string | Incident name. |
+| `reference` | string | Human-readable incident reference \(e.g., INC-123\). |
+| `summary` | string | Incident summary, when set. |
+| `incident_status` | json | The incident status object \(id, name, category, rank\). |
+| `severity` | json | The incident severity object \(id, name, rank\), when set. |
+| `mode` | string | Incident mode \(standard, retrospective, test, tutorial, stream\). |
+| `visibility` | string | Incident visibility \(public or private\). |
+| `permalink` | string | Link to the incident in incident.io, when present. |
+| `created_at` | string | ISO 8601 timestamp when the incident was created. |
+| `updated_at` | string | ISO 8601 timestamp when the incident was last updated. |
+| `new_status` | json | New status object \(status-updated events only; null otherwise\). |
+| `previous_status` | json | Previous status object \(status-updated events only; null otherwise\). |
+| `update_message` | string | Update message accompanying a status change \(status-updated events only; null otherwise\). |
+
diff --git a/apps/docs/content/docs/en/integrations/logs.mdx b/apps/docs/content/docs/en/integrations/logs.mdx
index 79dd12f2432..2295215662c 100644
--- a/apps/docs/content/docs/en/integrations/logs.mdx
+++ b/apps/docs/content/docs/en/integrations/logs.mdx
@@ -29,7 +29,7 @@ Query workflow run logs in the current workspace with the full Logs-page filter
| `workflowIds` | string | No | Comma-separated workflow IDs to filter by |
| `folderIds` | string | No | Comma-separated folder IDs to filter by \(descendants included\) |
| `level` | string | No | Comma-separated statuses: 'info', 'error', 'running', 'pending', 'cancelled'. Omit for all. |
-| `triggers` | string | No | Comma-separated trigger types \(api, webhook, schedule, manual, chat, mcp, a2a, workflow, sim, …\) |
+| `triggers` | string | No | Comma-separated trigger types \(api, webhook, schedule, manual, chat, mcp, workflow, sim, …\) |
| `startDate` | string | No | ISO 8601 timestamp; only runs at or after this time |
| `endDate` | string | No | ISO 8601 timestamp; only runs at or before this time |
| `search` | string | No | Free-text search across log fields |
diff --git a/apps/docs/content/docs/en/integrations/loops.mdx b/apps/docs/content/docs/en/integrations/loops.mdx
index 98b1df23c2f..d46ec807111 100644
--- a/apps/docs/content/docs/en/integrations/loops.mdx
+++ b/apps/docs/content/docs/en/integrations/loops.mdx
@@ -271,3 +271,275 @@ Retrieve a list of contact properties from your Loops account. Returns each prop
| ↳ `type` | string | The property data type \(string, number, boolean, date\) |
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### Loops Campaign Email Sent
+
+Trigger workflow when a Loops campaign email is sent
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., campaign.email.sent, loop.email.sent, transactional.email.sent\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `campaignId` | string | Campaign ID, present on campaign.email.sent |
+| `campaignName` | string | Campaign name, present on campaign.email.sent |
+| `loopId` | string | Loop \(workflow\) ID, present on loop.email.sent |
+| `loopName` | string | Loop \(workflow\) name, present on loop.email.sent |
+| `transactionalId` | string | Transactional email ID, present on transactional.email.sent |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+| `mailingLists` | json | Mailing lists the send targeted \(id, name, description, isPublic\); present on campaign and loop sends |
+
+
+---
+
+### Loops Email Clicked
+
+Trigger workflow when a link in a Loops email is clicked
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., email.delivered, email.opened, email.clicked\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `sourceType` | string | Source of the email: "campaign", "loop", or "transactional" |
+| `campaignId` | string | Campaign ID, present when sourceType is "campaign" |
+| `loopId` | string | Loop \(workflow\) ID, present when sourceType is "loop" |
+| `transactionalId` | string | Transactional email ID, present when sourceType is "transactional" |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+
+
+---
+
+### Loops Email Delivered
+
+Trigger workflow when a Loops email is delivered
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., email.delivered, email.opened, email.clicked\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `sourceType` | string | Source of the email: "campaign", "loop", or "transactional" |
+| `campaignId` | string | Campaign ID, present when sourceType is "campaign" |
+| `loopId` | string | Loop \(workflow\) ID, present when sourceType is "loop" |
+| `transactionalId` | string | Transactional email ID, present when sourceType is "transactional" |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+
+
+---
+
+### Loops Email Hard Bounced
+
+Trigger workflow when a Loops email hard bounces
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., email.delivered, email.opened, email.clicked\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `sourceType` | string | Source of the email: "campaign", "loop", or "transactional" |
+| `campaignId` | string | Campaign ID, present when sourceType is "campaign" |
+| `loopId` | string | Loop \(workflow\) ID, present when sourceType is "loop" |
+| `transactionalId` | string | Transactional email ID, present when sourceType is "transactional" |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+
+
+---
+
+### Loops Email Opened
+
+Trigger workflow when a Loops email is opened
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., email.delivered, email.opened, email.clicked\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `sourceType` | string | Source of the email: "campaign", "loop", or "transactional" |
+| `campaignId` | string | Campaign ID, present when sourceType is "campaign" |
+| `loopId` | string | Loop \(workflow\) ID, present when sourceType is "loop" |
+| `transactionalId` | string | Transactional email ID, present when sourceType is "transactional" |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+
+
+---
+
+### Loops Email Soft Bounced
+
+Trigger workflow when a Loops email soft bounces
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., email.delivered, email.opened, email.clicked\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `sourceType` | string | Source of the email: "campaign", "loop", or "transactional" |
+| `campaignId` | string | Campaign ID, present when sourceType is "campaign" |
+| `loopId` | string | Loop \(workflow\) ID, present when sourceType is "loop" |
+| `transactionalId` | string | Transactional email ID, present when sourceType is "transactional" |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+
+
+---
+
+### Loops Loop Email Sent
+
+Trigger workflow when a Loops loop email is sent
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., campaign.email.sent, loop.email.sent, transactional.email.sent\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `campaignId` | string | Campaign ID, present on campaign.email.sent |
+| `campaignName` | string | Campaign name, present on campaign.email.sent |
+| `loopId` | string | Loop \(workflow\) ID, present on loop.email.sent |
+| `loopName` | string | Loop \(workflow\) name, present on loop.email.sent |
+| `transactionalId` | string | Transactional email ID, present on transactional.email.sent |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+| `mailingLists` | json | Mailing lists the send targeted \(id, name, description, isPublic\); present on campaign and loop sends |
+
+
+---
+
+### Loops Transactional Email Sent
+
+Trigger workflow when a Loops transactional email is sent
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Required to verify the webhook signature from Loops. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventName` | string | Event type \(e.g., campaign.email.sent, loop.email.sent, transactional.email.sent\) |
+| `eventTime` | number | Unix timestamp \(seconds\) when the event occurred |
+| `webhookSchemaVersion` | string | Webhook schema version \(e.g., "1.0.0"\) |
+| `campaignId` | string | Campaign ID, present on campaign.email.sent |
+| `campaignName` | string | Campaign name, present on campaign.email.sent |
+| `loopId` | string | Loop \(workflow\) ID, present on loop.email.sent |
+| `loopName` | string | Loop \(workflow\) name, present on loop.email.sent |
+| `transactionalId` | string | Transactional email ID, present on transactional.email.sent |
+| `email` | json | Email object from the payload \(id, emailMessageId, subject\) |
+| `emailId` | string | Unique email ID \(payload `email.id`\) |
+| `emailMessageId` | string | Sent email message ID \(payload `email.emailMessageId`\) |
+| `subject` | string | Email subject line \(payload `email.subject`\) |
+| `contactIdentity` | json | Contact identity object from the payload \(id, email, userId\) |
+| `contactId` | string | Contact ID \(payload `contactIdentity.id`\) |
+| `contactEmail` | string | Contact email address \(payload `contactIdentity.email`\) |
+| `userId` | string | Contact user ID, when set \(payload `contactIdentity.userId`\) |
+| `mailingLists` | json | Mailing lists the send targeted \(id, name, description, isPublic\); present on campaign and loop sends |
+
diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json
index 03fe5fc2f23..f9f8e3a7c26 100644
--- a/apps/docs/content/docs/en/integrations/meta.json
+++ b/apps/docs/content/docs/en/integrations/meta.json
@@ -1,6 +1,7 @@
{
"pages": [
"index",
+ "a2a",
"agentmail",
"agentphone",
"agiloft",
@@ -215,6 +216,7 @@
"tinybird",
"trello",
"trigger_dev",
+ "twilio",
"twilio_sms",
"twilio_voice",
"typeform",
diff --git a/apps/docs/content/docs/en/integrations/microsoft_excel.mdx b/apps/docs/content/docs/en/integrations/microsoft_excel.mdx
index 61b1158144c..818186cb31a 100644
--- a/apps/docs/content/docs/en/integrations/microsoft_excel.mdx
+++ b/apps/docs/content/docs/en/integrations/microsoft_excel.mdx
@@ -88,4 +88,145 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet
| ↳ `spreadsheetId` | string | Microsoft Excel spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
+### `microsoft_excel_clear_range`
+
+Clear the values and/or formatting of a range in a Microsoft Excel worksheet
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook \(e.g., "01ABC123DEF456"\) |
+| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
+| `sheetName` | string | No | The name of the worksheet \(e.g., "Sheet1"\). If omitted, the range must use the combined "Sheet1!A1:B2" format. |
+| `range` | string | Yes | The cell range to clear \(e.g., "A1:D10" or "Sheet1!A1:D10"\) |
+| `applyTo` | string | No | What to clear: "All", "Formats", or "Contents". Defaults to "All". |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `cleared` | boolean | Whether the range was cleared |
+| `range` | string | The range that was cleared |
+| `applyTo` | string | What was cleared \(All, Formats, or Contents\) |
+| `metadata` | object | Spreadsheet metadata |
+| ↳ `spreadsheetId` | string | The ID of the spreadsheet |
+| ↳ `spreadsheetUrl` | string | URL to access the spreadsheet |
+
+### `microsoft_excel_format_range`
+
+Apply fill color and/or font formatting to a range in a Microsoft Excel worksheet
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook \(e.g., "01ABC123DEF456"\) |
+| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
+| `sheetName` | string | No | The name of the worksheet \(e.g., "Sheet1"\). If omitted, the range must use the combined "Sheet1!A1:B2" format. |
+| `range` | string | Yes | The cell range to format \(e.g., "A1:D10" or "Sheet1!A1:D10"\) |
+| `fillColor` | string | No | Background fill color as an HTML hex code \(e.g., "#FFFF00"\). |
+| `fontBold` | boolean | No | Whether the font is bold. |
+| `fontItalic` | boolean | No | Whether the font is italic. |
+| `fontColor` | string | No | Font color as an HTML hex code \(e.g., "#FF0000"\). |
+| `fontSize` | number | No | Font size in points \(e.g., 12\). |
+| `fontName` | string | No | Font name \(e.g., "Calibri"\). |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `formatted` | boolean | Whether the formatting was applied |
+| `range` | string | The range that was formatted |
+| `fill` | object | The applied fill, or null if no fill was set |
+| ↳ `color` | string | The applied fill color |
+| `font` | object | The applied font properties, or null if no font was set |
+| ↳ `bold` | boolean | Whether the font is bold |
+| ↳ `italic` | boolean | Whether the font is italic |
+| ↳ `color` | string | The font color |
+| ↳ `name` | string | The font name |
+| ↳ `size` | number | The font size in points |
+| `metadata` | object | Spreadsheet metadata |
+| ↳ `spreadsheetId` | string | The ID of the spreadsheet |
+| ↳ `spreadsheetUrl` | string | URL to access the spreadsheet |
+
+### `microsoft_excel_create_table`
+
+Create a new table over a range of cells in a Microsoft Excel workbook
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook \(e.g., "01ABC123DEF456"\) |
+| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
+| `address` | string | Yes | The range address for the table data source \(e.g., "Sheet1!A1:D5"\). If no sheet name is included, the active sheet is used. |
+| `hasHeaders` | boolean | No | Whether the first row of the range contains column headers. Defaults to true. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `table` | object | Details of the newly created table |
+| ↳ `id` | string | The unique ID of the table |
+| ↳ `name` | string | The name of the table |
+| ↳ `showHeaders` | boolean | Whether the header row is shown |
+| ↳ `showTotals` | boolean | Whether the totals row is shown |
+| ↳ `style` | string | The table style name |
+| `metadata` | object | Spreadsheet metadata |
+| ↳ `spreadsheetId` | string | The ID of the spreadsheet |
+| ↳ `spreadsheetUrl` | string | URL to access the spreadsheet |
+
+### `microsoft_excel_delete_worksheet`
+
+Delete a worksheet (sheet) from a Microsoft Excel workbook
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook \(e.g., "01ABC123DEF456"\) |
+| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
+| `worksheetName` | string | Yes | The name of the worksheet to delete \(e.g., "Sheet1", "Old Data"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the worksheet was deleted |
+| `worksheetName` | string | The name of the deleted worksheet |
+| `metadata` | object | Spreadsheet metadata |
+| ↳ `spreadsheetId` | string | The ID of the spreadsheet |
+| ↳ `spreadsheetUrl` | string | URL to access the spreadsheet |
+
+### `microsoft_excel_sort_range`
+
+Sort a range or table by a column in a Microsoft Excel worksheet
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook \(e.g., "01ABC123DEF456"\) |
+| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
+| `tableName` | string | No | The name of the table to sort. When provided, the table is sorted and range/sheetName are ignored. |
+| `sheetName` | string | No | The name of the worksheet \(e.g., "Sheet1"\). Used for range sorts when the range does not include a sheet name. |
+| `range` | string | No | The cell range to sort \(e.g., "A1:D10" or "Sheet1!A1:D10"\). Required when no table name is provided. |
+| `sortColumn` | number | Yes | The zero-based column index within the range or table to sort on \(0 = first column\). |
+| `sortAscending` | boolean | No | Whether to sort in ascending order. Defaults to true. |
+| `hasHeaders` | boolean | No | Whether the range has a header row that should be excluded from sorting. Only applies to range sorts. Defaults to false. |
+| `matchCase` | boolean | No | Whether casing affects string ordering. Defaults to false. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `sorted` | boolean | Whether the sort was applied |
+| `target` | string | The range or table name that was sorted |
+| `sortColumn` | number | The zero-based column index that was sorted on |
+| `ascending` | boolean | Whether the sort was ascending |
+| `metadata` | object | Spreadsheet metadata |
+| ↳ `spreadsheetId` | string | The ID of the spreadsheet |
+| ↳ `spreadsheetUrl` | string | URL to access the spreadsheet |
+
diff --git a/apps/docs/content/docs/en/integrations/notion.mdx b/apps/docs/content/docs/en/integrations/notion.mdx
index 4ddf64db96c..af14a062afc 100644
--- a/apps/docs/content/docs/en/integrations/notion.mdx
+++ b/apps/docs/content/docs/en/integrations/notion.mdx
@@ -273,6 +273,289 @@ Create a new database in Notion with custom properties
| `last_edited_time` | string | ISO 8601 last edit timestamp |
| `title` | string | Row title |
+### `notion_append_blocks`
+
+Append new block children (content) to a Notion page or block
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `blockId` | string | Yes | The UUID of the page or block to append children to |
+| `children` | json | Yes | Array of Notion block objects to append \(max 100\) |
+| `after` | string | No | UUID of an existing block to append the new children after |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
+### `notion_retrieve_block_children`
+
+Retrieve the block children (content) of a Notion page or block
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `blockId` | string | Yes | The UUID of the page or block whose children to retrieve |
+| `startCursor` | string | No | Pagination cursor returned by a previous request |
+| `pageSize` | number | No | Number of results to return \(1-100, default 100\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
+### `notion_update_block`
+
+Update the content or archived state of a single Notion block
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `blockId` | string | Yes | The UUID of the block to update |
+| `block` | json | Yes | Block-type object with the fields to update, e.g. \{"paragraph": \{"rich_text": \[...\]\}\} |
+| `archived` | boolean | No | Set to true to archive \(delete\) the block, or false to restore it |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
+### `notion_delete_block`
+
+Delete (move to trash) a single Notion block
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `blockId` | string | Yes | The UUID of the block to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
+### `notion_create_comment`
+
+Create a comment on a Notion page or within an existing discussion thread
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `pageId` | string | No | UUID of the page to comment on \(provide either pageId or discussionId\) |
+| `discussionId` | string | No | UUID of an existing discussion thread to reply to |
+| `content` | string | Yes | The text content of the comment |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
+### `notion_list_comments`
+
+List unresolved comments on a Notion page or block
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `blockId` | string | Yes | The UUID of the page or block whose comments to list |
+| `startCursor` | string | No | Pagination cursor returned by a previous request |
+| `pageSize` | number | No | Number of results to return \(1-100, default 100\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
+### `notion_list_users`
+
+List all users (members and bots) in the Notion workspace
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `startCursor` | string | No | Pagination cursor returned by a previous request |
+| `pageSize` | number | No | Number of results to return \(1-100, default 100\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
+### `notion_retrieve_user`
+
+Retrieve a single Notion user by their UUID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `userId` | string | Yes | The UUID of the user to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `content` | string | Page content in markdown format, or comment text for create comment |
+| `title` | string | Page or database title |
+| `url` | string | Notion URL |
+| `id` | string | Page, database, block, comment, or user ID |
+| `created_time` | string | Creation timestamp |
+| `last_edited_time` | string | Last edit timestamp |
+| `results` | array | Array of results \(pages, blocks, comments, or users\) |
+| `has_more` | boolean | Whether more results are available |
+| `next_cursor` | string | Cursor for pagination |
+| `total_results` | number | Number of results returned |
+| `properties` | json | Database properties schema |
+| `appended` | boolean | Whether content was successfully appended |
+| `type` | string | Block type |
+| `block` | json | The full updated Notion block object |
+| `archived` | boolean | Whether the block was archived |
+| `discussion_id` | string | Discussion thread ID |
+| `name` | string | User display name |
+| `avatar_url` | string | User avatar image URL |
+| `email` | string | User email address \(person users only\) |
+
## Triggers
diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx
index d99b8b83839..f7341289fe2 100644
--- a/apps/docs/content/docs/en/integrations/outlook.mdx
+++ b/apps/docs/content/docs/en/integrations/outlook.mdx
@@ -1,6 +1,6 @@
---
title: Outlook
-description: Send, read, draft, forward, and move Outlook email messages
+description: Send, read, search, reply, organize, and manage Outlook email
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -37,7 +37,7 @@ By connecting Sim with Microsoft Outlook, you enable intelligent agents to autom
## Usage Instructions
-Integrate Outlook into the workflow. Can read, draft, send, forward, and move email messages. Can be used in trigger mode to trigger a workflow when a new email is received.
+Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can be used in trigger mode to trigger a workflow when a new email is received.
@@ -139,6 +139,91 @@ Read emails from Outlook
| ↳ `importance` | string | Message importance \(low, normal, high\) |
| `attachments` | file[] | All email attachments flattened from all emails |
+### `outlook_search`
+
+Search Outlook messages using a free-text query (Microsoft Graph $search)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `query` | string | Yes | Search text matched against the subject, body, sender, and recipients of messages |
+| `maxResults` | number | No | Maximum number of messages to retrieve \(default: 10, max: 25\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or status message |
+| `results` | array | Array of matching email message objects |
+| ↳ `id` | string | Unique message identifier |
+| ↳ `subject` | string | Email subject |
+| ↳ `bodyPreview` | string | Preview of the message body |
+| ↳ `body` | object | Message body |
+| ↳ `contentType` | string | Body content type \(text or html\) |
+| ↳ `content` | string | Body content |
+| ↳ `sender` | object | Sender information |
+| ↳ `name` | string | Display name of the person or entity |
+| ↳ `address` | string | Email address |
+| ↳ `from` | object | From address information |
+| ↳ `name` | string | Display name of the person or entity |
+| ↳ `address` | string | Email address |
+| ↳ `toRecipients` | array | To recipients |
+| ↳ `name` | string | Display name of the person or entity |
+| ↳ `address` | string | Email address |
+| ↳ `ccRecipients` | array | CC recipients |
+| ↳ `name` | string | Display name of the person or entity |
+| ↳ `address` | string | Email address |
+| ↳ `receivedDateTime` | string | When the message was received \(ISO 8601\) |
+| ↳ `sentDateTime` | string | When the message was sent \(ISO 8601\) |
+| ↳ `hasAttachments` | boolean | Whether the message has attachments |
+| ↳ `isRead` | boolean | Whether the message has been read |
+| ↳ `importance` | string | Message importance \(low, normal, high\) |
+
+### `outlook_reply`
+
+Reply to the sender of an Outlook message with a comment
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `messageId` | string | Yes | The ID of the message to reply to |
+| `comment` | string | No | The reply text to include above the original message |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `results` | object | Reply result details |
+| ↳ `status` | string | Reply status |
+| ↳ `timestamp` | string | Timestamp when the reply was sent |
+| ↳ `httpStatus` | number | HTTP status code returned by the API |
+| ↳ `requestId` | string | Microsoft Graph request-id header for tracing |
+
+### `outlook_reply_all`
+
+Reply to all recipients of an Outlook message with a comment
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `messageId` | string | Yes | The ID of the message to reply to |
+| `comment` | string | No | The reply text to include above the original message |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `results` | object | Reply-all result details |
+| ↳ `status` | string | Reply status |
+| ↳ `timestamp` | string | Timestamp when the reply was sent |
+| ↳ `httpStatus` | number | HTTP status code returned by the API |
+| ↳ `requestId` | string | Microsoft Graph request-id header for tracing |
+
### `outlook_forward`
Forward an existing Outlook message to specified recipients
@@ -184,6 +269,27 @@ Move emails between Outlook folders
| `messageId` | string | ID of the moved message |
| `newFolderId` | string | ID of the destination folder |
+### `outlook_copy`
+
+Copy an Outlook message to another folder
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `messageId` | string | Yes | ID of the message to copy |
+| `destinationId` | string | Yes | ID of the destination folder |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Email copy success status |
+| `message` | string | Success or error message |
+| `originalMessageId` | string | ID of the original message |
+| `copiedMessageId` | string | ID of the copied message |
+| `destinationFolderId` | string | ID of the destination folder |
+
### `outlook_mark_read`
Mark an Outlook message as read
@@ -222,6 +328,32 @@ Mark an Outlook message as unread
| `messageId` | string | ID of the message |
| `isRead` | boolean | Read status of the message |
+### `outlook_update_message`
+
+Set the categories, follow-up flag, and importance on an Outlook message
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `messageId` | string | Yes | The ID of the message to update |
+| `categories` | json | No | Array of category names to assign to the message \(replaces existing categories; leave empty to keep them unchanged\) |
+| `flagStatus` | string | No | Follow-up flag status: notFlagged, flagged, or complete |
+| `importance` | string | No | Message importance: low, normal, or high |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `results` | object | Updated message details |
+| ↳ `messageId` | string | ID of the updated message |
+| ↳ `subject` | string | Subject of the message |
+| ↳ `categories` | array | Categories assigned to the message |
+| ↳ `flagStatus` | string | Follow-up flag status of the message |
+| ↳ `importance` | string | Importance of the message |
+| ↳ `isRead` | boolean | Whether the message is read |
+
### `outlook_delete`
Delete an Outlook message (move to Deleted Items)
@@ -241,26 +373,105 @@ Delete an Outlook message (move to Deleted Items)
| `messageId` | string | ID of the deleted message |
| `status` | string | Deletion status |
-### `outlook_copy`
+### `outlook_list_folders`
-Copy an Outlook message to another folder
+List mail folders in the root of the Outlook mailbox
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `messageId` | string | Yes | ID of the message to copy |
-| `destinationId` | string | Yes | ID of the destination folder |
+| `maxResults` | number | No | Maximum number of folders to retrieve \(default: 50, max: 100\) |
+| `includeHiddenFolders` | boolean | No | Whether to include hidden folders in the results |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `success` | boolean | Email copy success status |
-| `message` | string | Success or error message |
-| `originalMessageId` | string | ID of the original message |
-| `copiedMessageId` | string | ID of the copied message |
-| `destinationFolderId` | string | ID of the destination folder |
+| `message` | string | Success or status message |
+| `results` | array | Array of mail folder objects |
+| ↳ `id` | string | Unique folder identifier |
+| ↳ `displayName` | string | Display name of the folder |
+| ↳ `parentFolderId` | string | Identifier of the parent folder |
+| ↳ `childFolderCount` | number | Number of immediate child folders |
+| ↳ `unreadItemCount` | number | Number of unread items in the folder |
+| ↳ `totalItemCount` | number | Total number of items in the folder |
+| ↳ `isHidden` | boolean | Whether the folder is hidden |
+
+### `outlook_create_folder`
+
+Create a new mail folder in the root of the Outlook mailbox
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `displayName` | string | Yes | The display name of the new folder |
+| `isHidden` | boolean | No | Whether the new folder is hidden \(cannot be changed after creation\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or status message |
+| `results` | object | The newly created mail folder |
+| ↳ `id` | string | Unique folder identifier |
+| ↳ `displayName` | string | Display name of the folder |
+| ↳ `parentFolderId` | string | Identifier of the parent folder |
+| ↳ `childFolderCount` | number | Number of immediate child folders |
+| ↳ `unreadItemCount` | number | Number of unread items in the folder |
+| ↳ `totalItemCount` | number | Total number of items in the folder |
+| ↳ `isHidden` | boolean | Whether the folder is hidden |
+
+### `outlook_list_attachments`
+
+List the attachments on an Outlook message (metadata only, without contents)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `messageId` | string | Yes | The ID of the message whose attachments to list |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or status message |
+| `results` | array | Array of attachment metadata objects |
+| ↳ `id` | string | Unique attachment identifier |
+| ↳ `name` | string | Attachment filename |
+| ↳ `contentType` | string | MIME type of the attachment |
+| ↳ `size` | number | Attachment size in bytes |
+| ↳ `isInline` | boolean | Whether the attachment is rendered inline in the message body |
+| ↳ `attachmentType` | string | Microsoft Graph attachment type \(e.g. #microsoft.graph.fileAttachment\) |
+| ↳ `lastModifiedDateTime` | string | When the attachment was last modified \(ISO 8601\) |
+
+### `outlook_get_attachment`
+
+Get a single attachment on an Outlook message, including its file contents
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `messageId` | string | Yes | The ID of the message that owns the attachment |
+| `attachmentId` | string | Yes | The ID of the attachment to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or status message |
+| `results` | object | Attachment metadata |
+| ↳ `id` | string | Unique attachment identifier |
+| ↳ `name` | string | Attachment filename |
+| ↳ `contentType` | string | MIME type of the attachment |
+| ↳ `size` | number | Attachment size in bytes |
+| ↳ `isInline` | boolean | Whether the attachment is rendered inline in the message body |
+| ↳ `attachmentType` | string | Microsoft Graph attachment type \(e.g. #microsoft.graph.fileAttachment\) |
+| ↳ `lastModifiedDateTime` | string | When the attachment was last modified \(ISO 8601\) |
+| `attachments` | file[] | The downloaded file attachment \(empty for non-file attachment types\) |
diff --git a/apps/docs/content/docs/en/integrations/pinecone.mdx b/apps/docs/content/docs/en/integrations/pinecone.mdx
index c7fab08fc7e..d3b443f1d11 100644
--- a/apps/docs/content/docs/en/integrations/pinecone.mdx
+++ b/apps/docs/content/docs/en/integrations/pinecone.mdx
@@ -29,7 +29,7 @@ In Sim, the Pinecone integration enables your agents to leverage vector search c
## Usage Instructions
-Integrate Pinecone into the workflow. Can generate embeddings, upsert text, search with text, fetch vectors, and search with vectors.
+Integrate Pinecone into the workflow. Generate embeddings, upsert and update text records, delete vectors, search with text or vectors, fetch and list vectors, inspect index statistics, and manage indexes.
@@ -75,6 +75,49 @@ Insert or update text records in a Pinecone index
| --------- | ---- | ----------- |
| `statusText` | string | Status of the upsert operation |
+### `pinecone_update_vector`
+
+Update the values, sparse values, or metadata of a vector in a Pinecone namespace
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `indexHost` | string | Yes | Full Pinecone index host URL \(e.g., "https://my-index-abc123.svc.pinecone.io"\) |
+| `id` | string | Yes | Unique ID of the vector to update |
+| `namespace` | string | No | Namespace containing the vector \(e.g., "documents", "embeddings"\) |
+| `values` | array | No | New dense vector values to overwrite the existing values |
+| `sparseValues` | object | No | New sparse vector values with indices and values arrays |
+| `setMetadata` | object | No | Metadata key-value pairs to add or overwrite on the vector |
+| `apiKey` | string | Yes | Pinecone API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `statusText` | string | Status of the update operation |
+
+### `pinecone_delete_vectors`
+
+Delete vectors from a Pinecone namespace by IDs, by metadata filter, or delete all
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `indexHost` | string | Yes | Full Pinecone index host URL \(e.g., "https://my-index-abc123.svc.pinecone.io"\) |
+| `namespace` | string | No | Namespace to delete vectors from \(e.g., "documents", "embeddings"\) |
+| `ids` | array | No | Vector IDs to delete \(1-1000 items\). Mutually exclusive with deleteAll and filter |
+| `deleteAll` | boolean | No | Delete all vectors in the namespace. Mutually exclusive with ids and filter |
+| `filter` | object | No | Metadata filter selecting vectors to delete \(e.g., \{"category": \{"$eq": "product"\}\}\). Mutually exclusive with ids and deleteAll |
+| `apiKey` | string | Yes | Pinecone API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `statusText` | string | Status of the delete operation |
+
### `pinecone_search_text`
Search for similar text in a Pinecone index
@@ -157,4 +200,102 @@ Fetch vectors by ID from a Pinecone index
| `usage` | object | Usage statistics including total read units |
| ↳ `total_tokens` | number | Read units consumed |
+### `pinecone_list_vector_ids`
+
+List vector IDs in a Pinecone namespace by prefix (serverless indexes only)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `indexHost` | string | Yes | Full Pinecone index host URL \(e.g., "https://my-index-abc123.svc.pinecone.io"\) |
+| `namespace` | string | Yes | Namespace to list vector IDs from \(e.g., "documents", "embeddings"\) |
+| `prefix` | string | No | Filter vector IDs by a common prefix \(e.g., "doc1#"\) |
+| `limit` | number | No | Maximum number of IDs to return per page \(default 100\) |
+| `paginationToken` | string | No | Pagination token from a previous response to fetch the next page |
+| `apiKey` | string | Yes | Pinecone API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `vectorIds` | array | Vector IDs in the namespace |
+| `pagination` | object | Pagination info with a next token when more results exist |
+| ↳ `next` | string | Token to fetch the next page |
+| `namespace` | string | Namespace the IDs were listed from |
+| `usage` | object | Usage statistics including read units |
+| ↳ `total_tokens` | number | Read units consumed |
+
+### `pinecone_describe_index_stats`
+
+Get statistics about a Pinecone index, including per-namespace vector counts
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `indexHost` | string | Yes | Full Pinecone index host URL \(e.g., "https://my-index-abc123.svc.pinecone.io"\) |
+| `filter` | object | No | Metadata filter to limit which vectors are counted \(pod-based indexes only, e.g., \{"category": \{"$eq": "product"\}\}\) |
+| `apiKey` | string | Yes | Pinecone API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `namespaces` | json | Map of namespace name to its summary including vectorCount |
+| `dimension` | number | Dimensionality of the indexed vectors |
+| `indexFullness` | number | Fullness of the index \(pod-based indexes only\) |
+| `totalVectorCount` | number | Total number of vectors across all namespaces |
+
+### `pinecone_list_indexes`
+
+List all Pinecone indexes in the project
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Pinecone API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `indexes` | array | List of indexes with name, dimension, metric, host, spec, and status |
+| ↳ `name` | string | Index name |
+| ↳ `dimension` | number | Vector dimensionality |
+| ↳ `metric` | string | Distance metric \(cosine, euclidean, dotproduct\) |
+| ↳ `host` | string | Index host URL for data-plane operations |
+| ↳ `vectorType` | string | Vector type \(dense or sparse\) |
+| ↳ `deletionProtection` | string | Deletion protection \(enabled or disabled\) |
+| ↳ `tags` | object | Custom user tags on the index |
+| ↳ `spec` | object | Index spec \(serverless or pod configuration\) |
+| ↳ `status` | object | Index status with ready and state |
+
+### `pinecone_describe_index`
+
+Get the configuration and status of a Pinecone index by name
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `indexName` | string | Yes | Name of the index to describe |
+| `apiKey` | string | Yes | Pinecone API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `index` | object | Index configuration and status |
+| ↳ `name` | string | Index name |
+| ↳ `dimension` | number | Vector dimensionality |
+| ↳ `metric` | string | Distance metric \(cosine, euclidean, dotproduct\) |
+| ↳ `host` | string | Index host URL for data-plane operations |
+| ↳ `vectorType` | string | Vector type \(dense or sparse\) |
+| ↳ `deletionProtection` | string | Deletion protection \(enabled or disabled\) |
+| ↳ `tags` | object | Custom user tags on the index |
+| ↳ `spec` | object | Index spec \(serverless or pod configuration\) |
+| ↳ `status` | object | Index status with ready and state |
+
diff --git a/apps/docs/content/docs/en/integrations/resend.mdx b/apps/docs/content/docs/en/integrations/resend.mdx
index 53671593923..0adc6415400 100644
--- a/apps/docs/content/docs/en/integrations/resend.mdx
+++ b/apps/docs/content/docs/en/integrations/resend.mdx
@@ -94,6 +94,23 @@ Retrieve details of a previously sent email by its ID
| ↳ `name` | string | Tag name |
| ↳ `value` | string | Tag value |
+### `resend_cancel_email`
+
+Cancel a scheduled email before it is sent
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `cancelEmailId` | string | Yes | The ID of the scheduled email to cancel |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Canceled email ID |
+
### `resend_create_contact`
Create a new contact in Resend
@@ -197,6 +214,151 @@ Delete a contact from Resend by ID or email
| `id` | string | Deleted contact ID |
| `deleted` | boolean | Whether the contact was successfully deleted |
+### `resend_create_audience`
+
+Create a new audience in Resend
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `audienceName` | string | Yes | The name of the audience to create |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Created audience ID |
+| `name` | string | Audience name |
+
+### `resend_get_audience`
+
+Retrieve details of an audience by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `audienceId` | string | Yes | The ID of the audience to retrieve |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Audience ID |
+| `name` | string | Audience name |
+| `createdAt` | string | Audience creation timestamp |
+
+### `resend_list_audiences`
+
+List all audiences in Resend
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `audiences` | array | Array of audiences |
+| ↳ `id` | string | Audience ID |
+| ↳ `name` | string | Audience name |
+| ↳ `created_at` | string | Audience creation timestamp |
+| `hasMore` | boolean | Whether there are more audiences to retrieve |
+
+### `resend_delete_audience`
+
+Delete an audience from Resend by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `audienceId` | string | Yes | The ID of the audience to delete |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Deleted audience ID |
+| `deleted` | boolean | Whether the audience was successfully deleted |
+
+### `resend_create_broadcast`
+
+Create a broadcast email for an audience in Resend
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `audienceId` | string | Yes | The ID of the audience to send the broadcast to |
+| `broadcastFrom` | string | Yes | Sender email address \(e.g., "sender@example.com" or "Sender Name <sender@example.com>"\) |
+| `broadcastSubject` | string | Yes | Broadcast email subject line |
+| `broadcastHtml` | string | No | HTML content of the broadcast |
+| `broadcastText` | string | No | Plain text content of the broadcast |
+| `broadcastReplyTo` | string | No | Reply-to email address |
+| `broadcastName` | string | No | Friendly internal name for the broadcast |
+| `broadcastPreviewText` | string | No | Preview text shown in the inbox before the email is opened |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Created broadcast ID |
+
+### `resend_send_broadcast`
+
+Send a broadcast immediately or schedule it for later
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `broadcastId` | string | Yes | The ID of the broadcast to send |
+| `broadcastScheduledAt` | string | No | Schedule delivery in natural language \(e.g., "in 1 min"\) or ISO 8601 format. Sends immediately if omitted |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Broadcast ID |
+
+### `resend_get_broadcast`
+
+Retrieve details of a broadcast by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `broadcastId` | string | Yes | The ID of the broadcast to retrieve |
+| `resendApiKey` | string | Yes | Resend API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Broadcast ID |
+| `name` | string | Broadcast name |
+| `audienceId` | string | Audience ID \(legacy\) |
+| `segmentId` | string | Segment ID \(the current recipient field\) |
+| `from` | string | Sender email address |
+| `subject` | string | Broadcast subject |
+| `replyTo` | string | Reply-to email address |
+| `previewText` | string | Inbox preview text |
+| `status` | string | Broadcast status \(e.g., draft, sent\) |
+| `createdAt` | string | Broadcast creation timestamp |
+| `scheduledAt` | string | Scheduled send timestamp |
+| `sentAt` | string | Timestamp the broadcast was sent |
+
### `resend_list_domains`
List all verified domains in your Resend account
diff --git a/apps/docs/content/docs/en/integrations/revenuecat.mdx b/apps/docs/content/docs/en/integrations/revenuecat.mdx
index 5822fc971ac..beed6b324d9 100644
--- a/apps/docs/content/docs/en/integrations/revenuecat.mdx
+++ b/apps/docs/content/docs/en/integrations/revenuecat.mdx
@@ -455,3 +455,329 @@ Immediately revoke access to a Google Play subscription and issue a refund (Goog
| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key |
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### RevenueCat Cancellation
+
+Trigger workflow when a subscriber cancels a RevenueCat subscription
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Secret API key with the project_configuration:integrations:read_write permission. Sim uses it to create and remove the webhook in RevenueCat. |
+| `projectId` | string | Yes | RevenueCat project identifier the webhook integration is created in. |
+| `environment` | string | No | Restrict events to a single environment, or receive all of them. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., INITIAL_PURCHASE, RENEWAL\) |
+| `id` | string | Unique event identifier |
+| `app_id` | string | RevenueCat app public identifier |
+| `event_timestamp_ms` | number | Timestamp \(ms since epoch\) when the event was generated |
+| `app_user_id` | string | Current App User ID |
+| `original_app_user_id` | string | First App User ID ever used |
+| `aliases` | json | All App User IDs ever used by the subscriber |
+| `product_id` | string | Product identifier |
+| `new_product_id` | string | Product identifier changed to \(PRODUCT_CHANGE events only\) |
+| `period_type` | string | Period type \(TRIAL, INTRO, NORMAL, PROMOTIONAL, or PREPAID\) |
+| `purchased_at_ms` | number | Purchase timestamp \(ms since epoch\) |
+| `expiration_at_ms` | number | Expiration timestamp \(ms since epoch\), nullable |
+| `environment` | string | Environment \(SANDBOX or PRODUCTION\) |
+| `entitlement_id` | string | Deprecated single entitlement identifier |
+| `entitlement_ids` | json | Associated entitlement identifiers |
+| `presented_offering_id` | string | Identifier of the offering presented to the user |
+| `transaction_id` | string | Store transaction ID |
+| `original_transaction_id` | string | Original subscription transaction ID |
+| `is_family_share` | boolean | Whether the purchase was family shared |
+| `country_code` | string | ISO country code of the subscriber |
+| `currency` | string | ISO 4217 currency code |
+| `price` | number | Price in USD |
+| `price_in_purchased_currency` | number | Price in the currency the purchase was made in |
+| `store` | string | Store the purchase was made on \(e.g., APP_STORE\) |
+| `takehome_percentage` | number | Estimated percentage of the price taken home after store commission |
+| `tax_percentage` | number | Estimated percentage taken as tax |
+| `commission_percentage` | number | Estimated percentage taken by the store as commission |
+| `offer_code` | string | Offer code applied to the purchase, if any |
+| `subscriber_attributes` | json | Subscriber attributes at the time of the event |
+| `experiments` | json | Experiments the subscriber was enrolled in |
+| `cancel_reason` | string | Reason for cancellation \(CANCELLATION events only\) |
+| `expiration_reason` | string | Reason for expiration \(EXPIRATION events only\) |
+| `api_version` | string | RevenueCat webhook API version |
+| `event` | json | Full RevenueCat event object |
+
+
+---
+
+### RevenueCat Expiration
+
+Trigger workflow when a RevenueCat subscription expires
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Secret API key with the project_configuration:integrations:read_write permission. Sim uses it to create and remove the webhook in RevenueCat. |
+| `projectId` | string | Yes | RevenueCat project identifier the webhook integration is created in. |
+| `environment` | string | No | Restrict events to a single environment, or receive all of them. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., INITIAL_PURCHASE, RENEWAL\) |
+| `id` | string | Unique event identifier |
+| `app_id` | string | RevenueCat app public identifier |
+| `event_timestamp_ms` | number | Timestamp \(ms since epoch\) when the event was generated |
+| `app_user_id` | string | Current App User ID |
+| `original_app_user_id` | string | First App User ID ever used |
+| `aliases` | json | All App User IDs ever used by the subscriber |
+| `product_id` | string | Product identifier |
+| `new_product_id` | string | Product identifier changed to \(PRODUCT_CHANGE events only\) |
+| `period_type` | string | Period type \(TRIAL, INTRO, NORMAL, PROMOTIONAL, or PREPAID\) |
+| `purchased_at_ms` | number | Purchase timestamp \(ms since epoch\) |
+| `expiration_at_ms` | number | Expiration timestamp \(ms since epoch\), nullable |
+| `environment` | string | Environment \(SANDBOX or PRODUCTION\) |
+| `entitlement_id` | string | Deprecated single entitlement identifier |
+| `entitlement_ids` | json | Associated entitlement identifiers |
+| `presented_offering_id` | string | Identifier of the offering presented to the user |
+| `transaction_id` | string | Store transaction ID |
+| `original_transaction_id` | string | Original subscription transaction ID |
+| `is_family_share` | boolean | Whether the purchase was family shared |
+| `country_code` | string | ISO country code of the subscriber |
+| `currency` | string | ISO 4217 currency code |
+| `price` | number | Price in USD |
+| `price_in_purchased_currency` | number | Price in the currency the purchase was made in |
+| `store` | string | Store the purchase was made on \(e.g., APP_STORE\) |
+| `takehome_percentage` | number | Estimated percentage of the price taken home after store commission |
+| `tax_percentage` | number | Estimated percentage taken as tax |
+| `commission_percentage` | number | Estimated percentage taken by the store as commission |
+| `offer_code` | string | Offer code applied to the purchase, if any |
+| `subscriber_attributes` | json | Subscriber attributes at the time of the event |
+| `experiments` | json | Experiments the subscriber was enrolled in |
+| `cancel_reason` | string | Reason for cancellation \(CANCELLATION events only\) |
+| `expiration_reason` | string | Reason for expiration \(EXPIRATION events only\) |
+| `api_version` | string | RevenueCat webhook API version |
+| `event` | json | Full RevenueCat event object |
+
+
+---
+
+### RevenueCat Initial Purchase
+
+Trigger workflow when a subscriber makes their first purchase in RevenueCat
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Secret API key with the project_configuration:integrations:read_write permission. Sim uses it to create and remove the webhook in RevenueCat. |
+| `projectId` | string | Yes | RevenueCat project identifier the webhook integration is created in. |
+| `environment` | string | No | Restrict events to a single environment, or receive all of them. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., INITIAL_PURCHASE, RENEWAL\) |
+| `id` | string | Unique event identifier |
+| `app_id` | string | RevenueCat app public identifier |
+| `event_timestamp_ms` | number | Timestamp \(ms since epoch\) when the event was generated |
+| `app_user_id` | string | Current App User ID |
+| `original_app_user_id` | string | First App User ID ever used |
+| `aliases` | json | All App User IDs ever used by the subscriber |
+| `product_id` | string | Product identifier |
+| `new_product_id` | string | Product identifier changed to \(PRODUCT_CHANGE events only\) |
+| `period_type` | string | Period type \(TRIAL, INTRO, NORMAL, PROMOTIONAL, or PREPAID\) |
+| `purchased_at_ms` | number | Purchase timestamp \(ms since epoch\) |
+| `expiration_at_ms` | number | Expiration timestamp \(ms since epoch\), nullable |
+| `environment` | string | Environment \(SANDBOX or PRODUCTION\) |
+| `entitlement_id` | string | Deprecated single entitlement identifier |
+| `entitlement_ids` | json | Associated entitlement identifiers |
+| `presented_offering_id` | string | Identifier of the offering presented to the user |
+| `transaction_id` | string | Store transaction ID |
+| `original_transaction_id` | string | Original subscription transaction ID |
+| `is_family_share` | boolean | Whether the purchase was family shared |
+| `country_code` | string | ISO country code of the subscriber |
+| `currency` | string | ISO 4217 currency code |
+| `price` | number | Price in USD |
+| `price_in_purchased_currency` | number | Price in the currency the purchase was made in |
+| `store` | string | Store the purchase was made on \(e.g., APP_STORE\) |
+| `takehome_percentage` | number | Estimated percentage of the price taken home after store commission |
+| `tax_percentage` | number | Estimated percentage taken as tax |
+| `commission_percentage` | number | Estimated percentage taken by the store as commission |
+| `offer_code` | string | Offer code applied to the purchase, if any |
+| `subscriber_attributes` | json | Subscriber attributes at the time of the event |
+| `experiments` | json | Experiments the subscriber was enrolled in |
+| `cancel_reason` | string | Reason for cancellation \(CANCELLATION events only\) |
+| `expiration_reason` | string | Reason for expiration \(EXPIRATION events only\) |
+| `api_version` | string | RevenueCat webhook API version |
+| `event` | json | Full RevenueCat event object |
+
+
+---
+
+### RevenueCat Non-Renewing Purchase
+
+Trigger workflow when a subscriber makes a non-renewing purchase in RevenueCat
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Secret API key with the project_configuration:integrations:read_write permission. Sim uses it to create and remove the webhook in RevenueCat. |
+| `projectId` | string | Yes | RevenueCat project identifier the webhook integration is created in. |
+| `environment` | string | No | Restrict events to a single environment, or receive all of them. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., INITIAL_PURCHASE, RENEWAL\) |
+| `id` | string | Unique event identifier |
+| `app_id` | string | RevenueCat app public identifier |
+| `event_timestamp_ms` | number | Timestamp \(ms since epoch\) when the event was generated |
+| `app_user_id` | string | Current App User ID |
+| `original_app_user_id` | string | First App User ID ever used |
+| `aliases` | json | All App User IDs ever used by the subscriber |
+| `product_id` | string | Product identifier |
+| `new_product_id` | string | Product identifier changed to \(PRODUCT_CHANGE events only\) |
+| `period_type` | string | Period type \(TRIAL, INTRO, NORMAL, PROMOTIONAL, or PREPAID\) |
+| `purchased_at_ms` | number | Purchase timestamp \(ms since epoch\) |
+| `expiration_at_ms` | number | Expiration timestamp \(ms since epoch\), nullable |
+| `environment` | string | Environment \(SANDBOX or PRODUCTION\) |
+| `entitlement_id` | string | Deprecated single entitlement identifier |
+| `entitlement_ids` | json | Associated entitlement identifiers |
+| `presented_offering_id` | string | Identifier of the offering presented to the user |
+| `transaction_id` | string | Store transaction ID |
+| `original_transaction_id` | string | Original subscription transaction ID |
+| `is_family_share` | boolean | Whether the purchase was family shared |
+| `country_code` | string | ISO country code of the subscriber |
+| `currency` | string | ISO 4217 currency code |
+| `price` | number | Price in USD |
+| `price_in_purchased_currency` | number | Price in the currency the purchase was made in |
+| `store` | string | Store the purchase was made on \(e.g., APP_STORE\) |
+| `takehome_percentage` | number | Estimated percentage of the price taken home after store commission |
+| `tax_percentage` | number | Estimated percentage taken as tax |
+| `commission_percentage` | number | Estimated percentage taken by the store as commission |
+| `offer_code` | string | Offer code applied to the purchase, if any |
+| `subscriber_attributes` | json | Subscriber attributes at the time of the event |
+| `experiments` | json | Experiments the subscriber was enrolled in |
+| `cancel_reason` | string | Reason for cancellation \(CANCELLATION events only\) |
+| `expiration_reason` | string | Reason for expiration \(EXPIRATION events only\) |
+| `api_version` | string | RevenueCat webhook API version |
+| `event` | json | Full RevenueCat event object |
+
+
+---
+
+### RevenueCat Product Change
+
+Trigger workflow when a subscriber changes their RevenueCat subscription product
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Secret API key with the project_configuration:integrations:read_write permission. Sim uses it to create and remove the webhook in RevenueCat. |
+| `projectId` | string | Yes | RevenueCat project identifier the webhook integration is created in. |
+| `environment` | string | No | Restrict events to a single environment, or receive all of them. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., INITIAL_PURCHASE, RENEWAL\) |
+| `id` | string | Unique event identifier |
+| `app_id` | string | RevenueCat app public identifier |
+| `event_timestamp_ms` | number | Timestamp \(ms since epoch\) when the event was generated |
+| `app_user_id` | string | Current App User ID |
+| `original_app_user_id` | string | First App User ID ever used |
+| `aliases` | json | All App User IDs ever used by the subscriber |
+| `product_id` | string | Product identifier |
+| `new_product_id` | string | Product identifier changed to \(PRODUCT_CHANGE events only\) |
+| `period_type` | string | Period type \(TRIAL, INTRO, NORMAL, PROMOTIONAL, or PREPAID\) |
+| `purchased_at_ms` | number | Purchase timestamp \(ms since epoch\) |
+| `expiration_at_ms` | number | Expiration timestamp \(ms since epoch\), nullable |
+| `environment` | string | Environment \(SANDBOX or PRODUCTION\) |
+| `entitlement_id` | string | Deprecated single entitlement identifier |
+| `entitlement_ids` | json | Associated entitlement identifiers |
+| `presented_offering_id` | string | Identifier of the offering presented to the user |
+| `transaction_id` | string | Store transaction ID |
+| `original_transaction_id` | string | Original subscription transaction ID |
+| `is_family_share` | boolean | Whether the purchase was family shared |
+| `country_code` | string | ISO country code of the subscriber |
+| `currency` | string | ISO 4217 currency code |
+| `price` | number | Price in USD |
+| `price_in_purchased_currency` | number | Price in the currency the purchase was made in |
+| `store` | string | Store the purchase was made on \(e.g., APP_STORE\) |
+| `takehome_percentage` | number | Estimated percentage of the price taken home after store commission |
+| `tax_percentage` | number | Estimated percentage taken as tax |
+| `commission_percentage` | number | Estimated percentage taken by the store as commission |
+| `offer_code` | string | Offer code applied to the purchase, if any |
+| `subscriber_attributes` | json | Subscriber attributes at the time of the event |
+| `experiments` | json | Experiments the subscriber was enrolled in |
+| `cancel_reason` | string | Reason for cancellation \(CANCELLATION events only\) |
+| `expiration_reason` | string | Reason for expiration \(EXPIRATION events only\) |
+| `api_version` | string | RevenueCat webhook API version |
+| `event` | json | Full RevenueCat event object |
+
+
+---
+
+### RevenueCat Renewal
+
+Trigger workflow when a RevenueCat subscription renews
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Secret API key with the project_configuration:integrations:read_write permission. Sim uses it to create and remove the webhook in RevenueCat. |
+| `projectId` | string | Yes | RevenueCat project identifier the webhook integration is created in. |
+| `environment` | string | No | Restrict events to a single environment, or receive all of them. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., INITIAL_PURCHASE, RENEWAL\) |
+| `id` | string | Unique event identifier |
+| `app_id` | string | RevenueCat app public identifier |
+| `event_timestamp_ms` | number | Timestamp \(ms since epoch\) when the event was generated |
+| `app_user_id` | string | Current App User ID |
+| `original_app_user_id` | string | First App User ID ever used |
+| `aliases` | json | All App User IDs ever used by the subscriber |
+| `product_id` | string | Product identifier |
+| `new_product_id` | string | Product identifier changed to \(PRODUCT_CHANGE events only\) |
+| `period_type` | string | Period type \(TRIAL, INTRO, NORMAL, PROMOTIONAL, or PREPAID\) |
+| `purchased_at_ms` | number | Purchase timestamp \(ms since epoch\) |
+| `expiration_at_ms` | number | Expiration timestamp \(ms since epoch\), nullable |
+| `environment` | string | Environment \(SANDBOX or PRODUCTION\) |
+| `entitlement_id` | string | Deprecated single entitlement identifier |
+| `entitlement_ids` | json | Associated entitlement identifiers |
+| `presented_offering_id` | string | Identifier of the offering presented to the user |
+| `transaction_id` | string | Store transaction ID |
+| `original_transaction_id` | string | Original subscription transaction ID |
+| `is_family_share` | boolean | Whether the purchase was family shared |
+| `country_code` | string | ISO country code of the subscriber |
+| `currency` | string | ISO 4217 currency code |
+| `price` | number | Price in USD |
+| `price_in_purchased_currency` | number | Price in the currency the purchase was made in |
+| `store` | string | Store the purchase was made on \(e.g., APP_STORE\) |
+| `takehome_percentage` | number | Estimated percentage of the price taken home after store commission |
+| `tax_percentage` | number | Estimated percentage taken as tax |
+| `commission_percentage` | number | Estimated percentage taken by the store as commission |
+| `offer_code` | string | Offer code applied to the purchase, if any |
+| `subscriber_attributes` | json | Subscriber attributes at the time of the event |
+| `experiments` | json | Experiments the subscriber was enrolled in |
+| `cancel_reason` | string | Reason for cancellation \(CANCELLATION events only\) |
+| `expiration_reason` | string | Reason for expiration \(EXPIRATION events only\) |
+| `api_version` | string | RevenueCat webhook API version |
+| `event` | json | Full RevenueCat event object |
+
diff --git a/apps/docs/content/docs/en/integrations/rootly.mdx b/apps/docs/content/docs/en/integrations/rootly.mdx
index 5b45b01fe44..6ffb1303de5 100644
--- a/apps/docs/content/docs/en/integrations/rootly.mdx
+++ b/apps/docs/content/docs/en/integrations/rootly.mdx
@@ -1340,3 +1340,232 @@ List incident roles configured in Rootly (e.g. commander, scribe).
| `totalCount` | number | Total number of incident roles returned |
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### Rootly Alert Created
+
+Trigger workflow when a new alert is created in Rootly
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | API Key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventId` | string | Unique webhook event ID |
+| `eventType` | string | Rootly event type \(e.g. alert.created\) |
+| `issuedAt` | string | When the event was issued \(ISO 8601\) |
+| `data` | object | data output from the tool |
+| ↳ `id` | string | Alert ID |
+| ↳ `team_id` | number | Team ID |
+| ↳ `source` | string | Alert source \(e.g. pagerduty\) |
+| ↳ `summary` | string | Alert summary |
+| ↳ `labels` | json | Alert labels |
+| ↳ `data` | json | Raw alert payload data |
+| ↳ `external_id` | string | External alert ID |
+| ↳ `external_url` | string | External alert URL |
+| ↳ `webhook_type` | string | Webhook type |
+| ↳ `webhook_id` | string | Webhook ID |
+| ↳ `webhook_idempotency_key` | string | Webhook idempotency key |
+| ↳ `started_at` | string | When the alert started |
+| ↳ `ended_at` | string | When the alert ended |
+| ↳ `deleted_at` | string | When the alert was deleted |
+| ↳ `created_at` | string | Alert creation timestamp |
+| ↳ `updated_at` | string | Alert last update timestamp |
+
+
+---
+
+### Rootly Incident Created
+
+Trigger workflow when a new incident is created in Rootly
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | API Key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventId` | string | Unique webhook event ID |
+| `eventType` | string | Rootly event type \(e.g. incident.created\) |
+| `issuedAt` | string | When the event was issued \(ISO 8601\) |
+| `data` | object | data output from the tool |
+| ↳ `id` | string | Incident ID |
+| ↳ `sequential_id` | number | Sequential incident number |
+| ↳ `title` | string | Incident title |
+| ↳ `public_title` | string | Public-facing incident title |
+| ↳ `slug` | string | Incident slug |
+| ↳ `kind` | string | Incident kind \(normal, test, etc.\) |
+| ↳ `private` | boolean | Whether the incident is private |
+| ↳ `summary` | string | Incident summary |
+| ↳ `status` | string | Incident status |
+| ↳ `url` | string | Incident URL in Rootly |
+| ↳ `short_url` | string | Shortened incident URL |
+| ↳ `mitigation_message` | string | Mitigation message |
+| ↳ `resolution_message` | string | Resolution message |
+| ↳ `cancellation_message` | string | Cancellation message |
+| ↳ `slack_channel_name` | string | Linked Slack channel name |
+| ↳ `slack_channel_id` | string | Linked Slack channel ID |
+| ↳ `slack_channel_url` | string | Linked Slack channel URL |
+| ↳ `started_at` | string | When the incident started |
+| ↳ `detected_at` | string | When the incident was detected |
+| ↳ `acknowledged_at` | string | When the incident was acknowledged |
+| ↳ `mitigated_at` | string | When the incident was mitigated |
+| ↳ `resolved_at` | string | When the incident was resolved |
+| ↳ `cancelled_at` | string | When the incident was cancelled |
+| ↳ `created_at` | string | Incident creation timestamp |
+| ↳ `updated_at` | string | Incident last update timestamp |
+| ↳ `labels` | json | Incident labels \(key-value pairs\) |
+| ↳ `severity` | json | Incident severity object |
+| ↳ `user` | json | User who owns the incident |
+| ↳ `started_by` | json | User who started the incident |
+| ↳ `mitigated_by` | json | User who mitigated the incident |
+| ↳ `resolved_by` | json | User who resolved the incident |
+| ↳ `cancelled_by` | json | User who cancelled the incident |
+| ↳ `roles` | json | Assigned incident roles |
+| ↳ `environments` | json | Affected environments |
+| ↳ `incident_types` | json | Incident types |
+| ↳ `services` | json | Affected services |
+| ↳ `functionalities` | json | Affected functionalities |
+| ↳ `groups` | json | Associated teams/groups |
+| ↳ `events` | json | Timeline events |
+| ↳ `action_items` | json | Action items |
+| ↳ `incident_post_mortem` | json | Retrospective/post-mortem object |
+
+
+---
+
+### Rootly Incident Resolved
+
+Trigger workflow when an incident is resolved in Rootly
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | API Key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventId` | string | Unique webhook event ID |
+| `eventType` | string | Rootly event type \(e.g. incident.created\) |
+| `issuedAt` | string | When the event was issued \(ISO 8601\) |
+| `data` | object | data output from the tool |
+| ↳ `id` | string | Incident ID |
+| ↳ `sequential_id` | number | Sequential incident number |
+| ↳ `title` | string | Incident title |
+| ↳ `public_title` | string | Public-facing incident title |
+| ↳ `slug` | string | Incident slug |
+| ↳ `kind` | string | Incident kind \(normal, test, etc.\) |
+| ↳ `private` | boolean | Whether the incident is private |
+| ↳ `summary` | string | Incident summary |
+| ↳ `status` | string | Incident status |
+| ↳ `url` | string | Incident URL in Rootly |
+| ↳ `short_url` | string | Shortened incident URL |
+| ↳ `mitigation_message` | string | Mitigation message |
+| ↳ `resolution_message` | string | Resolution message |
+| ↳ `cancellation_message` | string | Cancellation message |
+| ↳ `slack_channel_name` | string | Linked Slack channel name |
+| ↳ `slack_channel_id` | string | Linked Slack channel ID |
+| ↳ `slack_channel_url` | string | Linked Slack channel URL |
+| ↳ `started_at` | string | When the incident started |
+| ↳ `detected_at` | string | When the incident was detected |
+| ↳ `acknowledged_at` | string | When the incident was acknowledged |
+| ↳ `mitigated_at` | string | When the incident was mitigated |
+| ↳ `resolved_at` | string | When the incident was resolved |
+| ↳ `cancelled_at` | string | When the incident was cancelled |
+| ↳ `created_at` | string | Incident creation timestamp |
+| ↳ `updated_at` | string | Incident last update timestamp |
+| ↳ `labels` | json | Incident labels \(key-value pairs\) |
+| ↳ `severity` | json | Incident severity object |
+| ↳ `user` | json | User who owns the incident |
+| ↳ `started_by` | json | User who started the incident |
+| ↳ `mitigated_by` | json | User who mitigated the incident |
+| ↳ `resolved_by` | json | User who resolved the incident |
+| ↳ `cancelled_by` | json | User who cancelled the incident |
+| ↳ `roles` | json | Assigned incident roles |
+| ↳ `environments` | json | Affected environments |
+| ↳ `incident_types` | json | Incident types |
+| ↳ `services` | json | Affected services |
+| ↳ `functionalities` | json | Affected functionalities |
+| ↳ `groups` | json | Associated teams/groups |
+| ↳ `events` | json | Timeline events |
+| ↳ `action_items` | json | Action items |
+| ↳ `incident_post_mortem` | json | Retrospective/post-mortem object |
+
+
+---
+
+### Rootly Incident Updated
+
+Trigger workflow when an incident is updated in Rootly
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | API Key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `eventId` | string | Unique webhook event ID |
+| `eventType` | string | Rootly event type \(e.g. incident.created\) |
+| `issuedAt` | string | When the event was issued \(ISO 8601\) |
+| `data` | object | data output from the tool |
+| ↳ `id` | string | Incident ID |
+| ↳ `sequential_id` | number | Sequential incident number |
+| ↳ `title` | string | Incident title |
+| ↳ `public_title` | string | Public-facing incident title |
+| ↳ `slug` | string | Incident slug |
+| ↳ `kind` | string | Incident kind \(normal, test, etc.\) |
+| ↳ `private` | boolean | Whether the incident is private |
+| ↳ `summary` | string | Incident summary |
+| ↳ `status` | string | Incident status |
+| ↳ `url` | string | Incident URL in Rootly |
+| ↳ `short_url` | string | Shortened incident URL |
+| ↳ `mitigation_message` | string | Mitigation message |
+| ↳ `resolution_message` | string | Resolution message |
+| ↳ `cancellation_message` | string | Cancellation message |
+| ↳ `slack_channel_name` | string | Linked Slack channel name |
+| ↳ `slack_channel_id` | string | Linked Slack channel ID |
+| ↳ `slack_channel_url` | string | Linked Slack channel URL |
+| ↳ `started_at` | string | When the incident started |
+| ↳ `detected_at` | string | When the incident was detected |
+| ↳ `acknowledged_at` | string | When the incident was acknowledged |
+| ↳ `mitigated_at` | string | When the incident was mitigated |
+| ↳ `resolved_at` | string | When the incident was resolved |
+| ↳ `cancelled_at` | string | When the incident was cancelled |
+| ↳ `created_at` | string | Incident creation timestamp |
+| ↳ `updated_at` | string | Incident last update timestamp |
+| ↳ `labels` | json | Incident labels \(key-value pairs\) |
+| ↳ `severity` | json | Incident severity object |
+| ↳ `user` | json | User who owns the incident |
+| ↳ `started_by` | json | User who started the incident |
+| ↳ `mitigated_by` | json | User who mitigated the incident |
+| ↳ `resolved_by` | json | User who resolved the incident |
+| ↳ `cancelled_by` | json | User who cancelled the incident |
+| ↳ `roles` | json | Assigned incident roles |
+| ↳ `environments` | json | Affected environments |
+| ↳ `incident_types` | json | Incident types |
+| ↳ `services` | json | Affected services |
+| ↳ `functionalities` | json | Affected functionalities |
+| ↳ `groups` | json | Associated teams/groups |
+| ↳ `events` | json | Timeline events |
+| ↳ `action_items` | json | Action items |
+| ↳ `incident_post_mortem` | json | Retrospective/post-mortem object |
+
diff --git a/apps/docs/content/docs/en/integrations/s3.mdx b/apps/docs/content/docs/en/integrations/s3.mdx
index 0678a74bc1b..63f8429c0a6 100644
--- a/apps/docs/content/docs/en/integrations/s3.mdx
+++ b/apps/docs/content/docs/en/integrations/s3.mdx
@@ -1,6 +1,6 @@
---
title: S3
-description: Upload, download, list, and manage S3 files
+description: Upload, download, list, and manage S3 files and buckets
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -29,7 +29,7 @@ In Sim, the S3 integration enables your agents to retrieve and access files stor
## Usage Instructions
-Integrate S3 into the workflow. Upload files, download objects, list bucket contents, delete objects, and copy objects between buckets. Requires AWS access key and secret access key.
+Integrate S3 into the workflow. Upload, download, copy, and delete objects (individually or in batches), inspect object metadata, generate time-limited presigned URLs, list bucket contents, and create, list, or delete buckets. Requires AWS access key and secret access key.
@@ -155,4 +155,144 @@ Copy an object within or between AWS S3 buckets
| `uri` | string | S3 URI of the copied object \(s3://bucket/key\) |
| `metadata` | object | Copy operation metadata |
+### `s3_list_buckets`
+
+List the S3 buckets owned by the authenticated AWS account
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKeyId` | string | Yes | Your AWS Access Key ID |
+| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
+| `region` | string | Yes | AWS region to address the request to \(e.g., us-east-1\) |
+| `prefix` | string | No | Limit the response to bucket names that begin with this prefix |
+| `maxBuckets` | number | No | Maximum number of buckets to return \(1-10000\) |
+| `continuationToken` | string | No | Token for pagination from a previous list buckets response |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `buckets` | array | List of S3 buckets owned by the account |
+| ↳ `name` | string | Bucket name |
+| ↳ `creationDate` | string | Bucket creation timestamp |
+| ↳ `region` | string | AWS region where the bucket is located |
+| `metadata` | object | Listing metadata including owner and pagination info |
+
+### `s3_head_object`
+
+Retrieve metadata for an S3 object without downloading its body
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKeyId` | string | Yes | Your AWS Access Key ID |
+| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
+| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
+| `bucketName` | string | Yes | S3 bucket name \(e.g., my-bucket\) |
+| `objectKey` | string | Yes | Object key/path to inspect \(e.g., folder/file.txt\) |
+| `versionId` | string | No | Specific object version ID to inspect \(for versioned buckets\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `exists` | boolean | Whether the object exists and was reachable |
+| `metadata` | object | Object metadata including size, content type, ETag, and last modified date |
+
+### `s3_create_bucket`
+
+Create a new AWS S3 bucket in the specified region
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKeyId` | string | Yes | Your AWS Access Key ID |
+| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
+| `region` | string | Yes | AWS region to create the bucket in \(e.g., us-east-1\) |
+| `bucketName` | string | Yes | Name for the new S3 bucket \(must be globally unique\) |
+| `acl` | string | No | Canned ACL for the bucket \(e.g., private, public-read\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `metadata` | object | Created bucket metadata including name and location |
+
+### `s3_delete_bucket`
+
+Delete an empty AWS S3 bucket
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKeyId` | string | Yes | Your AWS Access Key ID |
+| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
+| `region` | string | Yes | AWS region where the bucket is located \(e.g., us-east-1\) |
+| `bucketName` | string | Yes | Name of the S3 bucket to delete \(must be empty\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the bucket was successfully deleted |
+| `metadata` | object | Deletion metadata including bucket name |
+
+### `s3_presigned_url`
+
+Generate a time-limited presigned URL to download or upload an S3 object
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKeyId` | string | Yes | Your AWS Access Key ID |
+| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
+| `region` | string | Yes | AWS region where the bucket is located \(e.g., us-east-1\) |
+| `bucketName` | string | Yes | S3 bucket name \(e.g., my-bucket\) |
+| `objectKey` | string | Yes | Object key/path for the presigned URL \(e.g., folder/file.txt\) |
+| `method` | string | Yes | Operation the URL grants: get \(download\) or put \(upload\) |
+| `expiresIn` | number | No | URL validity in seconds \(1-604800, default 3600\) |
+| `contentType` | string | No | Content-Type the upload must use \(only applies to put URLs\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `url` | string | The generated presigned URL |
+| `metadata` | object | Presigned URL metadata including method and expiration |
+
+### `s3_delete_objects`
+
+Delete multiple objects from an AWS S3 bucket in a single batch request
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKeyId` | string | Yes | Your AWS Access Key ID |
+| `secretAccessKey` | string | Yes | Your AWS Secret Access Key |
+| `region` | string | Yes | AWS region \(e.g., us-east-1\) |
+| `bucketName` | string | Yes | S3 bucket name \(e.g., my-bucket\) |
+| `keys` | json | Yes | Array of object keys to delete \(e.g., \["a.txt", "folder/b.txt"\]\). Max 1000. |
+| `quiet` | boolean | No | Return only deletion errors, omitting successfully deleted keys |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | array | Objects that were successfully deleted |
+| ↳ `key` | string | Deleted object key |
+| ↳ `versionId` | string | Version ID of the deleted object |
+| ↳ `deleteMarker` | boolean | Whether a delete marker was created |
+| `errors` | array | Objects that failed to delete |
+| ↳ `key` | string | Object key that failed |
+| ↳ `code` | string | Error code |
+| ↳ `message` | string | Error message |
+| `metadata` | object | Batch deletion summary including counts |
+
diff --git a/apps/docs/content/docs/en/integrations/sentry.mdx b/apps/docs/content/docs/en/integrations/sentry.mdx
index 23bd02c3025..df3a9733acf 100644
--- a/apps/docs/content/docs/en/integrations/sentry.mdx
+++ b/apps/docs/content/docs/en/integrations/sentry.mdx
@@ -687,3 +687,223 @@ List all teams in a Sentry organization. Useful for discovering the team slug re
| ↳ `hasMore` | boolean | Whether there are more results available |
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### Sentry Error Created
+
+Trigger workflow when a new error event is created in Sentry
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `clientSecret` | string | Yes | Client Secret |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `action` | string | The action that triggered the webhook \(e.g., created, resolved, triggered\) |
+| `installation` | json | Installation object containing the integration installation uuid |
+| `actor` | json | Who triggered the webhook \(user, the integration application, or Sentry\) |
+| `error` | object | error output from the tool |
+| ↳ `event_id` | string | Unique event ID |
+| ↳ `issue_id` | string | ID of the issue this error belongs to |
+| ↳ `issue_url` | string | API URL of the issue |
+| ↳ `project` | number | Project ID |
+| ↳ `key_id` | string | Project key ID |
+| ↳ `level` | string | Error level |
+| ↳ `title` | string | Error title |
+| ↳ `eventType` | string | Event type \(the payload's `type` field; `type` is reserved\) |
+| ↳ `message` | string | Error message |
+| ↳ `culprit` | string | Error culprit \(location/transaction\) |
+| ↳ `platform` | string | Platform |
+| ↳ `logger` | string | Logger name |
+| ↳ `timestamp` | number | Event timestamp \(epoch seconds\) |
+| ↳ `datetime` | string | Event datetime \(ISO 8601\) |
+| ↳ `received` | number | Received timestamp \(epoch seconds\) |
+| ↳ `dist` | string | Distribution identifier |
+| ↳ `release` | string | Release version |
+| ↳ `fingerprint` | json | Grouping fingerprint |
+| ↳ `tags` | json | Event tags |
+| ↳ `user` | json | User context |
+| ↳ `request` | json | HTTP request context |
+| ↳ `contexts` | json | Additional contexts \(browser, os, device\) |
+| ↳ `sdk` | json | SDK information |
+| ↳ `exception` | json | Exception details including stack frames |
+| ↳ `metadata` | json | Error metadata |
+| ↳ `url` | string | API URL for the event |
+| ↳ `web_url` | string | Browser URL for the event |
+
+
+---
+
+### Sentry Issue Alert
+
+Trigger workflow when a Sentry issue alert rule fires
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `clientSecret` | string | Yes | Client Secret |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `action` | string | The action that triggered the webhook \(e.g., created, resolved, triggered\) |
+| `installation` | json | Installation object containing the integration installation uuid |
+| `actor` | json | Who triggered the webhook \(user, the integration application, or Sentry\) |
+| `event` | json | The event that triggered the alert rule |
+| `triggered_rule` | string | Label of the alert rule that was triggered |
+| `issue_alert` | object | issue_alert output from the tool |
+| ↳ `title` | string | Alert rule name |
+| ↳ `settings` | json | Alert rule action settings \(name/value pairs\) |
+
+
+---
+
+### Sentry Issue Created
+
+Trigger workflow when a new issue is created in Sentry
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `clientSecret` | string | Yes | Client Secret |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `action` | string | The action that triggered the webhook \(e.g., created, resolved, triggered\) |
+| `installation` | json | Installation object containing the integration installation uuid |
+| `actor` | json | Who triggered the webhook \(user, the integration application, or Sentry\) |
+| `issue` | object | issue output from the tool |
+| ↳ `id` | string | Issue ID |
+| ↳ `shortId` | string | Short human-readable issue ID |
+| ↳ `shareId` | string | Share ID for the issue |
+| ↳ `title` | string | Issue title |
+| ↳ `culprit` | string | Issue culprit \(location/transaction\) |
+| ↳ `logger` | string | Logger name |
+| ↳ `level` | string | Issue level \(error, warning, etc.\) |
+| ↳ `status` | string | Issue status \(unresolved, resolved, ignored\) |
+| ↳ `substatus` | string | Issue substatus |
+| ↳ `statusDetails` | json | Status details \(inRelease, inCommit, ignore*\) |
+| ↳ `platform` | string | Platform of the issue |
+| ↳ `eventType` | string | Issue type \(the payload's `type` field; `type` is reserved\) |
+| ↳ `issueType` | string | Specific issue type classification |
+| ↳ `issueCategory` | string | Issue category |
+| ↳ `isUnhandled` | boolean | Whether the issue is unhandled |
+| ↳ `isPublic` | boolean | Whether the issue is public |
+| ↳ `isBookmarked` | boolean | Whether the issue is bookmarked |
+| ↳ `isSubscribed` | boolean | Whether the viewer is subscribed |
+| ↳ `hasSeen` | boolean | Whether the issue has been seen |
+| ↳ `numComments` | number | Number of comments on the issue |
+| ↳ `count` | string | Total event count |
+| ↳ `userCount` | number | Number of affected users |
+| ↳ `firstSeen` | string | Timestamp when first seen |
+| ↳ `lastSeen` | string | Timestamp when last seen |
+| ↳ `priority` | string | Issue priority |
+| ↳ `assignedTo` | json | Assignee \(user or team\), or null |
+| ↳ `annotations` | json | Issue annotations |
+| ↳ `metadata` | json | Issue metadata \(title, type, value, sdk, severity\) |
+| ↳ `project` | object | project output from the tool |
+| ↳ `id` | string | Project ID |
+| ↳ `name` | string | Project name |
+| ↳ `slug` | string | Project slug |
+| ↳ `platform` | string | Project platform |
+| ↳ `url` | string | API URL for the issue |
+| ↳ `web_url` | string | Browser URL for the issue |
+| ↳ `project_url` | string | Browser URL for the project |
+| ↳ `permalink` | string | Permalink to the issue |
+
+
+---
+
+### Sentry Issue Resolved
+
+Trigger workflow when an issue is resolved in Sentry
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `clientSecret` | string | Yes | Client Secret |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `action` | string | The action that triggered the webhook \(e.g., created, resolved, triggered\) |
+| `installation` | json | Installation object containing the integration installation uuid |
+| `actor` | json | Who triggered the webhook \(user, the integration application, or Sentry\) |
+| `issue` | object | issue output from the tool |
+| ↳ `id` | string | Issue ID |
+| ↳ `shortId` | string | Short human-readable issue ID |
+| ↳ `shareId` | string | Share ID for the issue |
+| ↳ `title` | string | Issue title |
+| ↳ `culprit` | string | Issue culprit \(location/transaction\) |
+| ↳ `logger` | string | Logger name |
+| ↳ `level` | string | Issue level \(error, warning, etc.\) |
+| ↳ `status` | string | Issue status \(unresolved, resolved, ignored\) |
+| ↳ `substatus` | string | Issue substatus |
+| ↳ `statusDetails` | json | Status details \(inRelease, inCommit, ignore*\) |
+| ↳ `platform` | string | Platform of the issue |
+| ↳ `eventType` | string | Issue type \(the payload's `type` field; `type` is reserved\) |
+| ↳ `issueType` | string | Specific issue type classification |
+| ↳ `issueCategory` | string | Issue category |
+| ↳ `isUnhandled` | boolean | Whether the issue is unhandled |
+| ↳ `isPublic` | boolean | Whether the issue is public |
+| ↳ `isBookmarked` | boolean | Whether the issue is bookmarked |
+| ↳ `isSubscribed` | boolean | Whether the viewer is subscribed |
+| ↳ `hasSeen` | boolean | Whether the issue has been seen |
+| ↳ `numComments` | number | Number of comments on the issue |
+| ↳ `count` | string | Total event count |
+| ↳ `userCount` | number | Number of affected users |
+| ↳ `firstSeen` | string | Timestamp when first seen |
+| ↳ `lastSeen` | string | Timestamp when last seen |
+| ↳ `priority` | string | Issue priority |
+| ↳ `assignedTo` | json | Assignee \(user or team\), or null |
+| ↳ `annotations` | json | Issue annotations |
+| ↳ `metadata` | json | Issue metadata \(title, type, value, sdk, severity\) |
+| ↳ `project` | object | project output from the tool |
+| ↳ `id` | string | Project ID |
+| ↳ `name` | string | Project name |
+| ↳ `slug` | string | Project slug |
+| ↳ `platform` | string | Project platform |
+| ↳ `url` | string | API URL for the issue |
+| ↳ `web_url` | string | Browser URL for the issue |
+| ↳ `project_url` | string | Browser URL for the project |
+| ↳ `permalink` | string | Permalink to the issue |
+
+
+---
+
+### Sentry Metric Alert
+
+Trigger workflow when a Sentry metric alert changes state (critical, warning, resolved)
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `clientSecret` | string | Yes | Client Secret |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `action` | string | The action that triggered the webhook \(e.g., created, resolved, triggered\) |
+| `installation` | json | Installation object containing the integration installation uuid |
+| `actor` | json | Who triggered the webhook \(user, the integration application, or Sentry\) |
+| `metric_alert` | json | Metric alert object \(alert_rule + incident details\) |
+| `description_text` | string | Human-friendly description of the alert |
+| `description_title` | string | Human-friendly title of the alert |
+| `web_url` | string | API URL for the incident |
+
diff --git a/apps/docs/content/docs/en/integrations/telegram.mdx b/apps/docs/content/docs/en/integrations/telegram.mdx
index d167edcea02..98dd2d12246 100644
--- a/apps/docs/content/docs/en/integrations/telegram.mdx
+++ b/apps/docs/content/docs/en/integrations/telegram.mdx
@@ -53,7 +53,7 @@ Learn how to use the Telegram Tool in Sim to seamlessly automate message deliver
## Usage Instructions
-Integrate Telegram into the workflow. Can send and delete messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat.
+Integrate Telegram into the workflow. Send, edit, forward, copy, pin, and delete messages; send media, locations, contacts, and polls; react to messages; show chat actions; and look up chat and member info. Can be used in trigger mode to start a workflow when a message is sent to a chat.
@@ -376,6 +376,285 @@ Send documents (PDF, ZIP, DOC, etc.) to Telegram channels or users through the T
| ↳ `file_unique_id` | string | Unique document file identifier |
| ↳ `file_size` | number | Size of document file in bytes |
+### `telegram_edit_message_text`
+
+Edit the text of an existing message in a Telegram chat or channel through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `messageId` | number | Yes | Identifier of the message to edit |
+| `text` | string | Yes | New text of the message |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Edited Telegram message data |
+| ↳ `message_id` | number | Unique Telegram message identifier |
+| ↳ `date` | number | Unix timestamp when message was sent |
+| ↳ `text` | string | Text content of the edited message |
+
+### `telegram_forward_message`
+
+Forward a message from one Telegram chat to another through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Destination chat ID \(numeric, can be negative for groups\) |
+| `fromChatId` | string | Yes | Source chat ID the original message belongs to |
+| `messageId` | number | Yes | Identifier of the message to forward in the source chat |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Forwarded Telegram message data |
+| ↳ `message_id` | number | Identifier of the forwarded message |
+| ↳ `date` | number | Unix timestamp when message was sent |
+| ↳ `text` | string | Text content of the forwarded message |
+
+### `telegram_copy_message`
+
+Copy a message to another Telegram chat without a forward header through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Destination chat ID \(numeric, can be negative for groups\) |
+| `fromChatId` | string | Yes | Source chat ID the original message belongs to |
+| `messageId` | number | Yes | Identifier of the message to copy in the source chat |
+| `caption` | string | No | New caption for the copied media \(keeps the original if omitted\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Copied message identifier |
+| ↳ `message_id` | number | Identifier of the new copied message |
+
+### `telegram_send_location`
+
+Send a point on the map to a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `latitude` | number | Yes | Latitude of the location |
+| `longitude` | number | Yes | Longitude of the location |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Telegram message data for the sent location |
+| ↳ `message_id` | number | Unique Telegram message identifier |
+| ↳ `date` | number | Unix timestamp when message was sent |
+
+### `telegram_send_contact`
+
+Send a phone contact to a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `phoneNumber` | string | Yes | Contact's phone number |
+| `firstName` | string | Yes | Contact's first name |
+| `lastName` | string | No | Contact's last name |
+| `vcard` | string | No | Additional data about the contact in the form of a vCard |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Telegram message data for the sent contact |
+| ↳ `message_id` | number | Unique Telegram message identifier |
+| ↳ `date` | number | Unix timestamp when message was sent |
+
+### `telegram_send_poll`
+
+Send a native poll to a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `question` | string | Yes | Poll question \(1-300 characters\) |
+| `options` | json | Yes | List of 2-10 answer options as text strings |
+| `isAnonymous` | boolean | No | Whether the poll needs to be anonymous \(defaults to true\) |
+| `allowsMultipleAnswers` | boolean | No | Whether the poll allows multiple answers |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Telegram message data for the sent poll |
+| ↳ `message_id` | number | Unique Telegram message identifier |
+| ↳ `date` | number | Unix timestamp when message was sent |
+
+### `telegram_pin_message`
+
+Pin a message in a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `messageId` | number | Yes | Identifier of the message to pin |
+| `disableNotification` | boolean | No | Pass true to pin silently without notifying chat members |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Pin operation result |
+| ↳ `ok` | boolean | API response success status |
+| ↳ `result` | boolean | Whether the message was pinned |
+
+### `telegram_unpin_message`
+
+Unpin a pinned message in a Telegram chat through the Telegram Bot API. Unpins the most recent pinned message when no message ID is given.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `messageId` | number | No | Identifier of the message to unpin \(omit to unpin the most recent one\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Unpin operation result |
+| ↳ `ok` | boolean | API response success status |
+| ↳ `result` | boolean | Whether the message was unpinned |
+
+### `telegram_set_message_reaction`
+
+Set or remove an emoji reaction on a message in a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `messageId` | number | Yes | Identifier of the target message |
+| `reaction` | string | No | Emoji to react with \(leave empty to remove the reaction\) |
+| `isBig` | boolean | No | Pass true to show the reaction with a big animation |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Reaction operation result |
+| ↳ `ok` | boolean | API response success status |
+| ↳ `result` | boolean | Whether the reaction was set |
+
+### `telegram_send_chat_action`
+
+Show a status action such as a typing indicator in a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) |
+| `action` | string | Yes | Type of action to broadcast \(e.g. typing, upload_photo, record_video, upload_document, find_location\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Chat action result |
+| ↳ `ok` | boolean | API response success status |
+| ↳ `result` | boolean | Whether the action was broadcast |
+
+### `telegram_get_chat`
+
+Get up-to-date information about a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID or @username \(numeric, can be negative for groups\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Telegram chat information |
+| ↳ `id` | number | Unique chat identifier |
+| ↳ `type` | string | Chat type \(private, group, supergroup, channel\) |
+| ↳ `title` | string | Chat title for groups and channels |
+| ↳ `username` | string | Chat username, if available |
+| ↳ `first_name` | string | First name for private chats |
+| ↳ `last_name` | string | Last name for private chats |
+| ↳ `description` | string | Chat description |
+| ↳ `bio` | string | Bio of the other party in a private chat |
+| ↳ `invite_link` | string | Primary invite link for the chat |
+| ↳ `linked_chat_id` | number | Linked discussion or channel chat ID |
+
+### `telegram_get_chat_member`
+
+Get information about a member of a Telegram chat through the Telegram Bot API.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `botToken` | string | Yes | Your Telegram Bot API Token |
+| `chatId` | string | Yes | Telegram chat ID or @username \(numeric, can be negative for groups\) |
+| `userId` | number | Yes | Unique identifier of the target user |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Success or error message |
+| `data` | object | Telegram chat member information |
+| ↳ `status` | string | Member's status \(creator, administrator, member, restricted, left, kicked\) |
+| ↳ `user` | object | Information about the user |
+| ↳ `id` | number | Unique user identifier |
+| ↳ `is_bot` | boolean | Whether the user is a bot |
+| ↳ `first_name` | string | User's first name |
+| ↳ `last_name` | string | User's last name |
+| ↳ `username` | string | User's username |
+
## Triggers
diff --git a/apps/docs/content/docs/en/integrations/twilio.mdx b/apps/docs/content/docs/en/integrations/twilio.mdx
new file mode 100644
index 00000000000..6de83ac0136
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/twilio.mdx
@@ -0,0 +1,95 @@
+---
+title: Twilio
+description: Twilio triggers for automating workflows
+---
+
+import { BlockInfoCard } from "@/components/ui/block-info-card"
+
+
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### Twilio Message Status
+
+Trigger workflow when a Twilio message status changes (sent, delivered, failed)
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accountSid` | string | Yes | Your Twilio Account SID from the Twilio Console |
+| `authToken` | string | Yes | Your Twilio Auth Token, used to verify the X-Twilio-Signature header |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `messageSid` | string | Unique 34-character identifier for the message |
+| `accountSid` | string | Twilio Account SID |
+| `messagingServiceSid` | string | Messaging Service SID, if the message was sent through one |
+| `from` | string | Phone number or channel address that sent the message \(E.164 format\) |
+| `to` | string | Phone number or channel address of the recipient \(E.164 format\) |
+| `body` | string | Text body of the message \(up to 1600 characters\) |
+| `numMedia` | string | Number of media items attached to the message |
+| `numSegments` | string | Number of segments that make up the message |
+| `media` | json | Array of attached media as \{ url, contentType \} objects \(MMS\) |
+| `smsStatus` | string | SMS status \(e.g., received, sent, delivered, undelivered, failed\) |
+| `messageStatus` | string | Message status for status callbacks \(sent, delivered, undelivered, failed\) |
+| `errorCode` | string | Twilio error code, present when the status is failed or undelivered |
+| `apiVersion` | string | Twilio API version used to process the message |
+| `fromCity` | string | City of the sender, when available |
+| `fromState` | string | State/province of the sender, when available |
+| `fromZip` | string | Zip/postal code of the sender, when available |
+| `fromCountry` | string | Country of the sender, when available |
+| `toCity` | string | City of the recipient, when available |
+| `toState` | string | State/province of the recipient, when available |
+| `toZip` | string | Zip/postal code of the recipient, when available |
+| `toCountry` | string | Country of the recipient, when available |
+| `raw` | string | Complete raw webhook payload from Twilio as a JSON string |
+
+
+---
+
+### Twilio SMS Received
+
+Trigger workflow when an inbound SMS or MMS message is received via Twilio
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accountSid` | string | Yes | Your Twilio Account SID from the Twilio Console |
+| `authToken` | string | Yes | Your Twilio Auth Token, used to verify the X-Twilio-Signature header |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `messageSid` | string | Unique 34-character identifier for the message |
+| `accountSid` | string | Twilio Account SID |
+| `messagingServiceSid` | string | Messaging Service SID, if the message was sent through one |
+| `from` | string | Phone number or channel address that sent the message \(E.164 format\) |
+| `to` | string | Phone number or channel address of the recipient \(E.164 format\) |
+| `body` | string | Text body of the message \(up to 1600 characters\) |
+| `numMedia` | string | Number of media items attached to the message |
+| `numSegments` | string | Number of segments that make up the message |
+| `media` | json | Array of attached media as \{ url, contentType \} objects \(MMS\) |
+| `smsStatus` | string | SMS status \(e.g., received, sent, delivered, undelivered, failed\) |
+| `messageStatus` | string | Message status for status callbacks \(sent, delivered, undelivered, failed\) |
+| `errorCode` | string | Twilio error code, present when the status is failed or undelivered |
+| `apiVersion` | string | Twilio API version used to process the message |
+| `fromCity` | string | City of the sender, when available |
+| `fromState` | string | State/province of the sender, when available |
+| `fromZip` | string | Zip/postal code of the sender, when available |
+| `fromCountry` | string | Country of the sender, when available |
+| `toCity` | string | City of the recipient, when available |
+| `toState` | string | State/province of the recipient, when available |
+| `toZip` | string | Zip/postal code of the recipient, when available |
+| `toCountry` | string | Country of the recipient, when available |
+| `raw` | string | Complete raw webhook payload from Twilio as a JSON string |
+
diff --git a/apps/docs/content/docs/en/integrations/uptimerobot.mdx b/apps/docs/content/docs/en/integrations/uptimerobot.mdx
index f3876ecaf34..eb875883b30 100644
--- a/apps/docs/content/docs/en/integrations/uptimerobot.mdx
+++ b/apps/docs/content/docs/en/integrations/uptimerobot.mdx
@@ -146,7 +146,7 @@ Create a new monitor in UptimeRobot
| `type` | string | Yes | Monitor type: HTTP, KEYWORD, PING, PORT, HEARTBEAT, DNS, API, or UDP |
| `url` | string | No | URL or host to monitor \(not required for Heartbeat monitors\) |
| `interval` | number | Yes | Check interval in seconds \(minimum 30\) |
-| `timeout` | number | No | Check timeout in seconds, 0-60 \(HTTP, Keyword and Port monitors only\) |
+| `checkTimeout` | number | No | Check timeout in seconds, 0-60 \(HTTP, Keyword and Port monitors only\) |
| `port` | number | No | Port to check, 1-65535 \(required for Port and UDP monitors\) |
| `keywordType` | string | No | Keyword match type for Keyword monitors: ALERT_EXISTS or ALERT_NOT_EXISTS |
| `keywordValue` | string | No | Keyword to look for \(Keyword monitors only\) |
@@ -223,7 +223,7 @@ Update an existing UptimeRobot monitor. Only the provided fields are changed.
| `friendlyName` | string | No | New friendly name |
| `url` | string | No | New URL or host to monitor |
| `interval` | number | No | New check interval in seconds \(minimum 30\) |
-| `timeout` | number | No | New check timeout in seconds, 0-60 |
+| `checkTimeout` | number | No | New check timeout in seconds, 0-60 |
| `port` | number | No | New port, 1-65535 \(Port and UDP monitors\) |
| `keywordType` | string | No | Keyword match type: ALERT_EXISTS or ALERT_NOT_EXISTS |
| `keywordValue` | string | No | New keyword to look for |
@@ -583,7 +583,6 @@ Update an existing maintenance window. Only the provided fields are changed.
| `date` | string | No | Start date in YYYY-MM-DD format |
| `time` | string | No | Start time in HH:mm:ss format |
| `duration` | number | No | Duration in minutes \(minimum 1\) |
-| `autoAddMonitors` | boolean | No | Whether to automatically add all monitors to this window |
| `days` | string | No | Comma-separated days for weekly \(1-7\) or monthly \(day-of-month, -1 for last day\) windows |
| `monitorIds` | string | No | Comma-separated monitor IDs to assign to the window |
| `status` | string | No | Set to "active" to enable or "paused" to disable the maintenance window |
diff --git a/apps/docs/content/docs/en/integrations/whatsapp.mdx b/apps/docs/content/docs/en/integrations/whatsapp.mdx
index d7db0f573a1..d6c758ec6ec 100644
--- a/apps/docs/content/docs/en/integrations/whatsapp.mdx
+++ b/apps/docs/content/docs/en/integrations/whatsapp.mdx
@@ -26,7 +26,7 @@ In Sim, the WhatsApp integration enables your agents to leverage these messaging
## Usage Instructions
-Integrate WhatsApp into the workflow. Can send messages.
+Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API.
@@ -59,6 +59,199 @@ Send a text message through the WhatsApp Cloud API.
| ↳ `input` | string | Input phone number sent to the API |
| ↳ `wa_id` | string | WhatsApp user ID associated with the recipient |
+### `whatsapp_send_template`
+
+Send a pre-approved WhatsApp template message with a language and optional variable components.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) |
+| `templateName` | string | Yes | Name of the approved message template |
+| `languageCode` | string | Yes | Template language/locale code \(e.g., en_US\) |
+| `components` | json | No | Template components array with parameters for header/body/button variables, per the WhatsApp template message schema |
+| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Send success status |
+| `messageId` | string | WhatsApp message identifier |
+| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused |
+| `messagingProduct` | string | Messaging product returned by the send API |
+| `inputPhoneNumber` | string | Recipient phone number echoed by the send API |
+| `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient |
+| `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) |
+| `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed |
+| `from` | string | Sender phone number from the first incoming message |
+| `recipientId` | string | Recipient phone number from the first status update in the batch |
+| `phoneNumberId` | string | Business phone number ID from the first message or status item in the batch |
+| `displayPhoneNumber` | string | Business display phone number from the first message or status item in the batch |
+| `text` | string | Text body from the first incoming text message |
+| `timestamp` | string | Timestamp from the first message or status item in the batch |
+| `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system |
+| `status` | string | First outgoing message status in the batch, such as sent, delivered, or read |
+| `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) |
+| `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes |
+| `statuses` | json | All message status objects from the webhook batch, flattened across entries/changes |
+| `webhookContacts` | json | All sender contact profiles from the webhook batch |
+| `conversation` | json | Conversation metadata from the first status update in the batch \(id, expiration_timestamp, origin.type\) |
+| `pricing` | json | Pricing metadata from the first status update in the batch \(billable, pricing_model, category\) |
+| `raw` | json | Full structured WhatsApp webhook payload |
+| `error` | string | Error information if sending fails |
+
+### `whatsapp_send_media`
+
+Send an image, document, video, or audio message via a public link or an uploaded media ID.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) |
+| `mediaType` | string | Yes | Type of media to send: image, document, video, or audio |
+| `mediaLink` | string | No | Public HTTPS URL of the media \(provide this or mediaId\) |
+| `mediaId` | string | No | ID of media previously uploaded to WhatsApp \(provide this or mediaLink\) |
+| `caption` | string | No | Optional caption for image, video, or document media |
+| `filename` | string | No | Optional file name shown to the recipient for document media |
+| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Send success status |
+| `messageId` | string | WhatsApp message identifier |
+| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused |
+| `messagingProduct` | string | Messaging product returned by the send API |
+| `inputPhoneNumber` | string | Recipient phone number echoed by the send API |
+| `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient |
+| `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) |
+| `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed |
+| `from` | string | Sender phone number from the first incoming message |
+| `recipientId` | string | Recipient phone number from the first status update in the batch |
+| `phoneNumberId` | string | Business phone number ID from the first message or status item in the batch |
+| `displayPhoneNumber` | string | Business display phone number from the first message or status item in the batch |
+| `text` | string | Text body from the first incoming text message |
+| `timestamp` | string | Timestamp from the first message or status item in the batch |
+| `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system |
+| `status` | string | First outgoing message status in the batch, such as sent, delivered, or read |
+| `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) |
+| `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes |
+| `statuses` | json | All message status objects from the webhook batch, flattened across entries/changes |
+| `webhookContacts` | json | All sender contact profiles from the webhook batch |
+| `conversation` | json | Conversation metadata from the first status update in the batch \(id, expiration_timestamp, origin.type\) |
+| `pricing` | json | Pricing metadata from the first status update in the batch \(billable, pricing_model, category\) |
+| `raw` | json | Full structured WhatsApp webhook payload |
+| `error` | string | Error information if sending fails |
+
+### `whatsapp_send_interactive`
+
+Send an interactive WhatsApp message with reply buttons or a selectable list.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) |
+| `bodyText` | string | Yes | Main body text of the interactive message |
+| `headerText` | string | No | Optional plain-text header shown above the body |
+| `footerText` | string | No | Optional footer text shown below the body |
+| `buttons` | json | No | Reply buttons array \(max 3\), each item: \{ "type": "reply", "reply": \{ "id": "...", "title": "..." \} \}. Provide buttons or sections. |
+| `listButtonText` | string | No | Label for the menu button that opens the list \(required when sending a list\) |
+| `sections` | json | No | List sections array, each item: \{ "title": "...", "rows": \[\{ "id": "...", "title": "...", "description": "..." \}\] \}. Provide sections or buttons. |
+| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Send success status |
+| `messageId` | string | WhatsApp message identifier |
+| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused |
+| `messagingProduct` | string | Messaging product returned by the send API |
+| `inputPhoneNumber` | string | Recipient phone number echoed by the send API |
+| `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient |
+| `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) |
+| `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed |
+| `from` | string | Sender phone number from the first incoming message |
+| `recipientId` | string | Recipient phone number from the first status update in the batch |
+| `phoneNumberId` | string | Business phone number ID from the first message or status item in the batch |
+| `displayPhoneNumber` | string | Business display phone number from the first message or status item in the batch |
+| `text` | string | Text body from the first incoming text message |
+| `timestamp` | string | Timestamp from the first message or status item in the batch |
+| `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system |
+| `status` | string | First outgoing message status in the batch, such as sent, delivered, or read |
+| `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) |
+| `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes |
+| `statuses` | json | All message status objects from the webhook batch, flattened across entries/changes |
+| `webhookContacts` | json | All sender contact profiles from the webhook batch |
+| `conversation` | json | Conversation metadata from the first status update in the batch \(id, expiration_timestamp, origin.type\) |
+| `pricing` | json | Pricing metadata from the first status update in the batch \(billable, pricing_model, category\) |
+| `raw` | json | Full structured WhatsApp webhook payload |
+| `error` | string | Error information if sending fails |
+
+### `whatsapp_send_reaction`
+
+React to a WhatsApp message with an emoji. Send an empty emoji to remove an existing reaction.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) |
+| `messageId` | string | Yes | ID \(wamid\) of the message to react to |
+| `emoji` | string | No | Emoji to react with. Leave empty to remove an existing reaction. |
+| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Send success status |
+| `messageId` | string | WhatsApp message identifier |
+| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused |
+| `messagingProduct` | string | Messaging product returned by the send API |
+| `inputPhoneNumber` | string | Recipient phone number echoed by the send API |
+| `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient |
+| `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) |
+| `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed |
+| `from` | string | Sender phone number from the first incoming message |
+| `recipientId` | string | Recipient phone number from the first status update in the batch |
+| `phoneNumberId` | string | Business phone number ID from the first message or status item in the batch |
+| `displayPhoneNumber` | string | Business display phone number from the first message or status item in the batch |
+| `text` | string | Text body from the first incoming text message |
+| `timestamp` | string | Timestamp from the first message or status item in the batch |
+| `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system |
+| `status` | string | First outgoing message status in the batch, such as sent, delivered, or read |
+| `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) |
+| `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes |
+| `statuses` | json | All message status objects from the webhook batch, flattened across entries/changes |
+| `webhookContacts` | json | All sender contact profiles from the webhook batch |
+| `conversation` | json | Conversation metadata from the first status update in the batch \(id, expiration_timestamp, origin.type\) |
+| `pricing` | json | Pricing metadata from the first status update in the batch \(billable, pricing_model, category\) |
+| `raw` | json | Full structured WhatsApp webhook payload |
+| `error` | string | Error information if sending fails |
+
+### `whatsapp_mark_read`
+
+Mark a received WhatsApp message as read so the sender sees blue checkmarks.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `messageId` | string | Yes | ID \(wamid\) of the incoming message to mark as read |
+| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the message was successfully marked as read |
+
## Triggers
diff --git a/apps/docs/content/docs/en/logs-debugging/index.mdx b/apps/docs/content/docs/en/logs-debugging/index.mdx
index bc111469978..df544382d9a 100644
--- a/apps/docs/content/docs/en/logs-debugging/index.mdx
+++ b/apps/docs/content/docs/en/logs-debugging/index.mdx
@@ -24,7 +24,7 @@ The **Logs page** lists every run across your workspace, one row per run. The [L
### The run
-Each row on the Logs page is one **run**. It records the **trigger** that started it (manual, api, schedule, chat, webhook, mcp, mothership, copilot, workflow, or a2a), a **status**, a **duration**, and the **cost** in credits. It also carries an **execution ID** that uniquely names the run.
+Each row on the Logs page is one **run**. It records the **trigger** that started it (manual, api, schedule, chat, webhook, mcp, mothership, copilot, or workflow), a **status**, a **duration**, and the **cost** in credits. It also carries an **execution ID** that uniquely names the run.
The status shows the run's outcome at a glance, with failed runs badged **Error**. When you are hunting a failure, you filter the list to the errors and start there.
diff --git a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
index 7ae3ee72dc6..f71ad748aff 100644
--- a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
@@ -64,7 +64,7 @@ Controls which AI model providers members of this group can use.
Controls which workflow blocks members can place and execute.
-
Blocks are split into two sections: **Core Blocks** (Agent, API, Condition, Function, etc.) and **Tools** (all integration blocks).
+
Blocks are split into two sections: **Core Blocks** (Agent, API, Condition, Function, etc.) and **Tools** (all integration blocks).
- **All checked (default):** All blocks are allowed.
- **Subset checked:** Only the selected blocks are allowed. Workflows that already contain a disallowed block will fail when run — they are not automatically modified.
@@ -116,7 +116,6 @@ Controls visibility of platform features and modules.
|---------|-------------------|
| API | Hides the API deployment tab |
| MCP | Hides the MCP deployment tab |
-| A2A | Hides the A2A deployment tab |
| Chat | Hides the Chat deployment tab |
| Template | Hides the Template deployment tab |
diff --git a/apps/docs/content/docs/en/workflows/deployment/index.mdx b/apps/docs/content/docs/en/workflows/deployment/index.mdx
index 92bf4121d02..6975e45c9d2 100644
--- a/apps/docs/content/docs/en/workflows/deployment/index.mdx
+++ b/apps/docs/content/docs/en/workflows/deployment/index.mdx
@@ -7,7 +7,7 @@ pageType: concept
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
-A deployment is a published version of a [workflow](/workflows) that outside callers can run. While you build, a workflow lives on your canvas as a draft only you can run. Deploying publishes a fixed copy of that draft and gives it an address: a REST endpoint, a chat page, a set of [MCP](/workflows/deployment/mcp) tools, or an [A2A](/workflows/deployment/api) agent. Each is a **surface**, a channel callers reach the same published workflow through.
+A deployment is a published version of a [workflow](/workflows) that outside callers can run. While you build, a workflow lives on your canvas as a draft only you can run. Deploying publishes a fixed copy of that draft and gives it an address: a REST endpoint, a chat page, or a set of [MCP](/workflows/deployment/mcp) tools. Each is a **surface**, a channel callers reach the same published workflow through.
Like publishing a book, building and deploying are separate acts. You draft and revise freely on the canvas. When you click **Deploy**, Sim prints a fixed edition. Readers get that edition, not your latest draft, until you publish a new one. You can always go back to an earlier printing.
@@ -60,13 +60,12 @@ curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \
-d '{ "input": "Refund request from customer #4821" }'
```
-{/* VISUAL: deploy modal tab bar. General · API · MCP · A2A · Chat. */}
+{/* VISUAL: deploy modal tab bar. General · API · MCP · Chat. */}
-
## Versioning in practice
diff --git a/apps/docs/content/docs/en/workflows/how-it-runs.mdx b/apps/docs/content/docs/en/workflows/how-it-runs.mdx
index 2a808c2cde3..9bc42f226da 100644
--- a/apps/docs/content/docs/en/workflows/how-it-runs.mdx
+++ b/apps/docs/content/docs/en/workflows/how-it-runs.mdx
@@ -6,7 +6,13 @@ pageType: concept
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
-import { Image } from '@/components/ui/image'
+import {
+ COMBINATION_WORKFLOW,
+ CONCURRENCY_WORKFLOW,
+ ERROR_PATH_WORKFLOW,
+ ROUTING_WORKFLOW,
+ WorkflowPreview,
+} from '@/components/workflow-preview'
When you run a workflow, Sim works out the order from the [connections](/workflows/connections): a block runs as soon as the blocks it depends on have finished.
@@ -14,7 +20,7 @@ When you run a workflow, Sim works out the order from the [connections](/workflo
Multiple blocks in a workflow can be executing at the same time. A block starts the moment its dependencies finish, and it waits on nothing else.
-
+
Here the Customer Support and Deep Researcher agents each depend only on Start, so neither waits for the other.
@@ -22,7 +28,7 @@ Here the Customer Support and Deep Researcher agents each depend only on Start,
When several blocks feed into one, that block waits for every feeder that is going to run, then runs once with each of their outputs available to read. A feeder on a branch that wasn't taken doesn't hold it up. You don't merge the outputs yourself.
-
+
The Function block here runs after both agents complete, with both of their outputs ready.
@@ -30,7 +36,7 @@ The Function block here runs after both agents complete, with both of their outp
A workflow can split. A [Condition](/workflows/blocks/condition) block branches on an explicit rule; a [Router](/workflows/blocks/router) block lets a model choose the path. Only the branch that is taken runs. A block on a branch that didn't run produces no output, which is why a [connection tag](/workflows/connections) pointing at it comes back empty.
-
+
To repeat work, a [Loop](/workflows/blocks/loop) block runs its inner blocks over a list, a count, or while a condition holds, and a [Parallel](/workflows/blocks/parallel) block runs them for several items at once.
@@ -42,7 +48,7 @@ A workflow can call other workflows, through a Workflow block, an MCP tool, or a
A block that errors fails the run: blocks already running finish, and nothing new starts. To handle the failure instead, connect the block's **error port** — the run follows the [error path](/workflows/connections) and continues.
-
+
Here `throwError` fails, so the run leaves through its red error port to `handleError`; `handleSuccess` on the normal path never runs.
diff --git a/apps/docs/content/docs/es/tools/a2a.mdx b/apps/docs/content/docs/es/tools/a2a.mdx
deleted file mode 100644
index 9a5399da50b..00000000000
--- a/apps/docs/content/docs/es/tools/a2a.mdx
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: A2A
-description: Interactúa con agentes externos compatibles con A2A
----
-
-import { BlockInfoCard } from "@/components/ui/block-info-card"
-
-
-
-{/* MANUAL-CONTENT-START:intro */}
-El protocolo A2A (Agent-to-Agent) permite a Sim interactuar con agentes de IA externos y sistemas que implementan APIs compatibles con A2A. Con A2A, puedes conectar las automatizaciones y flujos de trabajo de Sim a agentes remotos—como bots potenciados por LLM, microservicios y otras herramientas basadas en IA—utilizando un formato de mensajería estandarizado.
-
-Usando las herramientas A2A en Sim, puedes:
-
-- **Enviar mensajes a agentes externos**: Comunícate directamente con agentes remotos, proporcionando prompts, comandos o datos.
-- **Recibir y transmitir respuestas**: Obtén respuestas estructuradas, artefactos o actualizaciones en tiempo real del agente a medida que avanza la tarea.
-- **Continuar conversaciones o tareas**: Mantén conversaciones o flujos de trabajo de múltiples turnos haciendo referencia a IDs de tarea y contexto.
-- **Integrar IA y automatización de terceros**: Aprovecha servicios externos compatibles con A2A como parte de tus flujos de trabajo en Sim.
-
-Estas funcionalidades te permiten construir flujos de trabajo avanzados que combinan las capacidades nativas de Sim con la inteligencia y automatización de IAs externas o agentes personalizados. Para usar integraciones A2A, necesitarás la URL del endpoint del agente externo y, si es necesario, una clave API o credenciales.
-{/* MANUAL-CONTENT-END */}
-
-## Instrucciones de uso
-
-Usa el protocolo A2A (Agent-to-Agent) para interactuar con agentes de IA externos.
-
-## Herramientas
-
-### `a2a_send_message`
-
-Envía un mensaje a un agente externo compatible con A2A.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `message` | string | Sí | Mensaje para enviar al agente |
-| `taskId` | string | No | ID de tarea para continuar una tarea existente |
-| `contextId` | string | No | ID de contexto para continuidad de conversación |
-| `data` | string | No | Datos estructurados para incluir con el mensaje \(cadena JSON\) |
-| `files` | array | No | Archivos para incluir con el mensaje |
-| `apiKey` | string | No | Clave API para autenticación |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `content` | string | Contenido de respuesta de texto del agente |
-| `taskId` | string | Identificador único de tarea |
-| `contextId` | string | Agrupa tareas/mensajes relacionados |
-| `state` | string | Estado actual del ciclo de vida \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `artifacts` | array | Artefactos de salida de la tarea |
-| `history` | array | Historial de conversación \(array de mensajes\) |
-
-### `a2a_get_task`
-
-Consulta el estado de una tarea A2A existente.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `taskId` | string | Sí | ID de tarea a consultar |
-| `apiKey` | string | No | Clave API para autenticación |
-| `historyLength` | number | No | Número de mensajes del historial a incluir |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `taskId` | string | Identificador único de tarea |
-| `contextId` | string | Agrupa tareas/mensajes relacionados |
-| `state` | string | Estado actual del ciclo de vida \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `artifacts` | array | Artefactos de salida de la tarea |
-| `history` | array | Historial de conversación \(array de mensajes\) |
-
-### `a2a_cancel_task`
-
-Cancela una tarea A2A en ejecución.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `taskId` | string | Sí | ID de tarea a cancelar |
-| `apiKey` | string | No | Clave API para autenticación |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `cancelled` | boolean | Si la cancelación fue exitosa |
-| `state` | string | Estado actual del ciclo de vida \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-
-### `a2a_get_agent_card`
-
-Obtener la tarjeta del agente (documento de descubrimiento) para un agente A2A.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `apiKey` | string | No | Clave API para autenticación \(si es requerida\) |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `name` | string | Nombre para mostrar del agente |
-| `description` | string | Propósito/capacidades del agente |
-| `url` | string | URL del endpoint del servicio |
-| `provider` | object | Detalles de la organización creadora |
-| `capabilities` | object | Matriz de soporte de características |
-| `skills` | array | Operaciones disponibles |
-| `version` | string | Versión del protocolo A2A soportada por el agente |
-| `defaultInputModes` | array | Tipos de contenido de entrada predeterminados aceptados por el agente |
-| `defaultOutputModes` | array | Tipos de contenido de salida predeterminados producidos por el agente |
-
-### `a2a_resubscribe`
-
-Reconectar a un flujo de tarea A2A en curso después de una interrupción de conexión.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `taskId` | string | Sí | ID de tarea para resuscribirse |
-| `apiKey` | string | No | Clave API para autenticación |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `taskId` | string | Identificador único de tarea |
-| `contextId` | string | Agrupa tareas/mensajes relacionados |
-| `state` | string | Estado actual del ciclo de vida \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `isRunning` | boolean | Si la tarea aún se está ejecutando |
-| `artifacts` | array | Artefactos de salida de la tarea |
-| `history` | array | Historial de conversación \(array de mensajes\) |
-
-### `a2a_set_push_notification`
-
-Configura un webhook para recibir notificaciones de actualización de tareas.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `taskId` | string | Sí | ID de tarea para configurar notificaciones |
-| `webhookUrl` | string | Sí | URL del webhook HTTPS para recibir notificaciones |
-| `token` | string | No | Token para validación del webhook |
-| `apiKey` | string | No | Clave API para autenticación |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `url` | string | URL del webhook HTTPS para notificaciones |
-| `token` | string | Token de autenticación para validación del webhook |
-| `success` | boolean | Si la operación fue exitosa |
-
-### `a2a_get_push_notification`
-
-Obtiene la configuración del webhook de notificaciones push para una tarea.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `taskId` | string | Sí | ID de tarea para obtener la configuración de notificaciones |
-| `apiKey` | string | No | Clave API para autenticación |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `token` | string | Token de autenticación para validación de webhook |
-| `exists` | boolean | Si el recurso existe |
-
-### `a2a_delete_push_notification`
-
-Elimina la configuración de webhook de notificaciones push para una tarea.
-
-#### Entrada
-
-| Parámetro | Tipo | Requerido | Descripción |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Sí | La URL del endpoint del agente A2A |
-| `taskId` | string | Sí | ID de tarea para eliminar la configuración de notificación |
-| `pushNotificationConfigId` | string | No | ID de configuración de notificación push a eliminar \(opcional - el servidor puede derivarlo del taskId\) |
-| `apiKey` | string | No | Clave API para autenticación |
-
-#### Salida
-
-| Parámetro | Tipo | Descripción |
-| --------- | ---- | ----------- |
-| `success` | boolean | Si la operación fue exitosa |
diff --git a/apps/docs/content/docs/fr/tools/a2a.mdx b/apps/docs/content/docs/fr/tools/a2a.mdx
deleted file mode 100644
index 710db594392..00000000000
--- a/apps/docs/content/docs/fr/tools/a2a.mdx
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: A2A
-description: Interagir avec des agents externes compatibles A2A
----
-
-import { BlockInfoCard } from "@/components/ui/block-info-card"
-
-
-
-{/* MANUAL-CONTENT-START:intro */}
-Le protocole A2A (Agent-to-Agent) permet à Sim d'interagir avec des agents IA externes et des systèmes qui implémentent des API compatibles A2A. Avec A2A, vous pouvez connecter les automatisations et workflows de Sim à des agents distants — tels que des bots alimentés par LLM, des microservices et d'autres outils basés sur l'IA — en utilisant un format de messagerie standardisé.
-
-En utilisant les outils A2A dans Sim, vous pouvez :
-
-- **Envoyer des messages à des agents externes** : communiquer directement avec des agents distants, en fournissant des invites, des commandes ou des données.
-- **Recevoir et diffuser des réponses** : obtenir des réponses structurées, des artefacts ou des mises à jour en temps réel de l'agent au fur et à mesure de la progression de la tâche.
-- **Poursuivre des conversations ou des tâches** : continuer des conversations ou des workflows multi-tours en référençant les ID de tâche et de contexte.
-- **Intégrer l'IA et l'automatisation tierces** : exploiter des services externes compatibles A2A dans le cadre de vos workflows Sim.
-
-Ces fonctionnalités vous permettent de créer des workflows avancés qui combinent les capacités natives de Sim avec l'intelligence et l'automatisation d'IA externes ou d'agents personnalisés. Pour utiliser les intégrations A2A, vous aurez besoin de l'URL du point de terminaison de l'agent externe et, si nécessaire, d'une clé API ou d'identifiants.
-{/* MANUAL-CONTENT-END */}
-
-## Instructions d'utilisation
-
-Utilisez le protocole A2A (Agent-to-Agent) pour interagir avec des agents IA externes.
-
-## Outils
-
-### `a2a_send_message`
-
-Envoyer un message à un agent externe compatible A2A.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `message` | string | Oui | Message à envoyer à l'agent |
-| `taskId` | string | Non | ID de tâche pour continuer une tâche existante |
-| `contextId` | string | Non | ID de contexte pour la continuité de la conversation |
-| `data` | string | Non | Données structurées à inclure avec le message \(chaîne JSON\) |
-| `files` | array | Non | Fichiers à inclure avec le message |
-| `apiKey` | string | Non | Clé API pour l'authentification |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `content` | string | Contenu de la réponse textuelle de l'agent |
-| `taskId` | string | Identifiant unique de la tâche |
-| `contextId` | string | Regroupe les tâches/messages associés |
-| `state` | string | État actuel du cycle de vie \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `artifacts` | array | Artefacts de sortie de la tâche |
-| `history` | array | Historique de la conversation \(tableau de messages\) |
-
-### `a2a_get_task`
-
-Interroger le statut d'une tâche A2A existante.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `taskId` | string | Oui | ID de la tâche à interroger |
-| `apiKey` | string | Non | Clé API pour l'authentification |
-| `historyLength` | number | Non | Nombre de messages d'historique à inclure |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `taskId` | string | Identifiant unique de la tâche |
-| `contextId` | string | Regroupe les tâches/messages associés |
-| `state` | string | État actuel du cycle de vie \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `artifacts` | array | Artefacts de sortie de la tâche |
-| `history` | array | Historique de la conversation \(tableau de messages\) |
-
-### `a2a_cancel_task`
-
-Annuler une tâche A2A en cours d'exécution.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `taskId` | string | Oui | ID de la tâche à annuler |
-| `apiKey` | string | Non | Clé API pour l'authentification |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `cancelled` | boolean | Indique si l'annulation a réussi |
-| `state` | string | État actuel du cycle de vie \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-
-### `a2a_get_agent_card`
-
-Récupère la carte d'agent (document de découverte) pour un agent A2A.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `apiKey` | string | Non | Clé API pour l'authentification \(si nécessaire\) |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `name` | string | Nom d'affichage de l'agent |
-| `description` | string | Objectif/capacités de l'agent |
-| `url` | string | URL du point de terminaison du service |
-| `provider` | object | Détails de l'organisation créatrice |
-| `capabilities` | object | Matrice de prise en charge des fonctionnalités |
-| `skills` | array | Opérations disponibles |
-| `version` | string | Version du protocole A2A prise en charge par l'agent |
-| `defaultInputModes` | array | Types de contenu d'entrée par défaut acceptés par l'agent |
-| `defaultOutputModes` | array | Types de contenu de sortie par défaut produits par l'agent |
-
-### `a2a_resubscribe`
-
-Se reconnecte à un flux de tâche A2A en cours après une interruption de connexion.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `taskId` | string | Oui | ID de la tâche à laquelle se réabonner |
-| `apiKey` | string | Non | Clé API pour l'authentification |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `taskId` | string | Identifiant unique de la tâche |
-| `contextId` | string | Regroupe les tâches/messages associés |
-| `state` | string | État actuel du cycle de vie \(working, completed, failed, canceled, rejected, input_required, auth_required\) |
-| `isRunning` | boolean | Indique si la tâche est toujours en cours d'exécution |
-| `artifacts` | array | Artefacts de sortie de la tâche |
-| `history` | array | Historique de la conversation \(tableau de messages\) |
-
-### `a2a_set_push_notification`
-
-Configurez un webhook pour recevoir les notifications de mise à jour des tâches.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `taskId` | string | Oui | ID de la tâche pour laquelle configurer les notifications |
-| `webhookUrl` | string | Oui | URL du webhook HTTPS pour recevoir les notifications |
-| `token` | string | Non | Jeton pour la validation du webhook |
-| `apiKey` | string | Non | Clé API pour l'authentification |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `url` | string | URL du webhook HTTPS pour les notifications |
-| `token` | string | Jeton d'authentification pour la validation du webhook |
-| `success` | boolean | Indique si l'opération a réussi |
-
-### `a2a_get_push_notification`
-
-Obtenez la configuration du webhook de notification push pour une tâche.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `taskId` | string | Oui | ID de la tâche pour laquelle obtenir la configuration des notifications |
-| `apiKey` | string | Non | Clé API pour l'authentification |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `token` | string | Jeton d'authentification pour la validation du webhook |
-| `exists` | boolean | Indique si la ressource existe |
-
-### `a2a_delete_push_notification`
-
-Supprime la configuration du webhook de notification push pour une tâche.
-
-#### Entrée
-
-| Paramètre | Type | Requis | Description |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | Oui | L'URL du point de terminaison de l'agent A2A |
-| `taskId` | string | Oui | ID de la tâche pour laquelle supprimer la configuration de notification |
-| `pushNotificationConfigId` | string | Non | ID de la configuration de notification push à supprimer \(optionnel - le serveur peut le déduire du taskId\) |
-| `apiKey` | string | Non | Clé API pour l'authentification |
-
-#### Sortie
-
-| Paramètre | Type | Description |
-| --------- | ---- | ----------- |
-| `success` | boolean | Indique si l'opération a réussi |
diff --git a/apps/docs/content/docs/ja/tools/a2a.mdx b/apps/docs/content/docs/ja/tools/a2a.mdx
deleted file mode 100644
index cf845ecc161..00000000000
--- a/apps/docs/content/docs/ja/tools/a2a.mdx
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: A2A
-description: 外部のA2A互換エージェントと連携
----
-
-import { BlockInfoCard } from "@/components/ui/block-info-card"
-
-
-
-{/* MANUAL-CONTENT-START:intro */}
-A2A(Agent-to-Agent)プロトコルにより、SimはA2A互換APIを実装した外部AIエージェントやシステムと連携できます。A2Aを使用すると、Simの自動化やワークフローを、LLM駆動のボット、マイクロサービス、その他のAIベースのツールなどのリモートエージェントに、標準化されたメッセージ形式で接続できます。
-
-SimのA2Aツールを使用すると、次のことができます。
-
-- **外部エージェントへのメッセージ送信**: リモートエージェントと直接通信し、プロンプト、コマンド、データを提供します。
-- **レスポンスの受信とストリーミング**: タスクの進行に応じて、エージェントから構造化されたレスポンス、アーティファクト、リアルタイム更新を取得します。
-- **会話やタスクの継続**: タスクIDとコンテキストIDを参照して、複数ターンの会話やワークフローを継続します。
-- **サードパーティAIと自動化の統合**: 外部のA2A互換サービスをSimワークフローの一部として活用します。
-
-これらの機能により、Simのネイティブ機能と外部AIやカスタムエージェントのインテリジェンスと自動化を組み合わせた高度なワークフローを構築できます。A2A統合を使用するには、外部エージェントのエンドポイントURLと、必要に応じてAPIキーまたは認証情報が必要です。
-{/* MANUAL-CONTENT-END */}
-
-## 使用方法
-
-A2A(Agent-to-Agent)プロトコルを使用して、外部AIエージェントと連携します。
-
-## ツール
-
-### `a2a_send_message`
-
-外部のA2A互換エージェントにメッセージを送信します。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2Aエージェントのエンドポイント URL |
-| `message` | string | はい | エージェントに送信するメッセージ |
-| `taskId` | string | いいえ | 既存のタスクを継続するためのタスクID |
-| `contextId` | string | いいえ | 会話の継続性のためのコンテキストID |
-| `data` | string | いいえ | メッセージに含める構造化データ(JSON文字列) |
-| `files` | array | いいえ | メッセージに含めるファイル |
-| `apiKey` | string | いいえ | 認証用のAPIキー |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `content` | string | エージェントからのテキストレスポンスコンテンツ |
-| `taskId` | string | 一意のタスク識別子 |
-| `contextId` | string | 関連するタスク/メッセージをグループ化 |
-| `state` | string | 現在のライフサイクル状態\(working、completed、failed、canceled、rejected、input_required、auth_required\) |
-| `artifacts` | array | タスク出力アーティファクト |
-| `history` | array | 会話履歴\(メッセージ配列\) |
-
-### `a2a_get_task`
-
-既存のA2Aタスクのステータスを照会します。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2AエージェントのエンドポイントURL |
-| `taskId` | string | はい | 照会するタスクID |
-| `apiKey` | string | いいえ | 認証用のAPIキー |
-| `historyLength` | number | いいえ | 含める履歴メッセージの数 |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `taskId` | string | 一意のタスク識別子 |
-| `contextId` | string | 関連するタスク/メッセージをグループ化 |
-| `state` | string | 現在のライフサイクル状態\(working、completed、failed、canceled、rejected、input_required、auth_required\) |
-| `artifacts` | array | タスク出力アーティファクト |
-| `history` | array | 会話履歴\(メッセージ配列\) |
-
-### `a2a_cancel_task`
-
-実行中のA2Aタスクをキャンセルします。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2AエージェントのエンドポイントURL |
-| `taskId` | string | はい | キャンセルするタスクID |
-| `apiKey` | string | いいえ | 認証用のAPIキー |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `cancelled` | boolean | キャンセルが成功したかどうか |
-| `state` | string | 現在のライフサイクル状態(working、completed、failed、canceled、rejected、input_required、auth_required) |
-
-### `a2a_get_agent_card`
-
-A2Aエージェントのエージェントカード(ディスカバリードキュメント)を取得します。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2AエージェントのエンドポイントURL |
-| `apiKey` | string | いいえ | 認証用のAPIキー(必要な場合) |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `name` | string | エージェントの表示名 |
-| `description` | string | エージェントの目的/機能 |
-| `url` | string | サービスエンドポイントURL |
-| `provider` | object | 作成者組織の詳細 |
-| `capabilities` | object | 機能サポートマトリックス |
-| `skills` | array | 利用可能な操作 |
-| `version` | string | エージェントがサポートするA2Aプロトコルバージョン |
-| `defaultInputModes` | array | エージェントが受け入れるデフォルトの入力コンテンツタイプ |
-| `defaultOutputModes` | array | エージェントが生成するデフォルトの出力コンテンツタイプ |
-
-### `a2a_resubscribe`
-
-接続中断後、進行中のA2Aタスクストリームに再接続します。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2AエージェントのエンドポイントURL |
-| `taskId` | string | はい | 再サブスクライブするタスクID |
-| `apiKey` | string | いいえ | 認証用のAPIキー |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `taskId` | string | 一意のタスク識別子 |
-| `contextId` | string | 関連するタスク/メッセージをグループ化 |
-| `state` | string | 現在のライフサイクル状態 \(working、completed、failed、canceled、rejected、input_required、auth_required\) |
-| `isRunning` | boolean | タスクが実行中かどうか |
-| `artifacts` | array | タスク出力アーティファクト |
-| `history` | array | 会話履歴 \(メッセージ配列\) |
-
-### `a2a_set_push_notification`
-
-タスク更新通知を受信するためのWebhookを設定します。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2AエージェントのエンドポイントURL |
-| `taskId` | string | はい | 通知を設定するタスクID |
-| `webhookUrl` | string | はい | 通知を受信するHTTPS Webhook URL |
-| `token` | string | いいえ | Webhook検証用トークン |
-| `apiKey` | string | いいえ | 認証用APIキー |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `url` | string | 通知用HTTPS Webhook URL |
-| `token` | string | Webhook検証用認証トークン |
-| `success` | boolean | 操作が成功したかどうか |
-
-### `a2a_get_push_notification`
-
-タスクのプッシュ通知Webhook設定を取得します。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2AエージェントのエンドポイントURL |
-| `taskId` | string | はい | 通知設定を取得するタスクID |
-| `apiKey` | string | いいえ | 認証用APIキー |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `token` | string | Webhook検証用の認証トークン |
-| `exists` | boolean | リソースが存在するかどうか |
-
-### `a2a_delete_push_notification`
-
-タスクのプッシュ通知Webhook設定を削除します。
-
-#### 入力
-
-| パラメータ | 型 | 必須 | 説明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | はい | A2AエージェントのエンドポイントURL |
-| `taskId` | string | はい | 通知設定を削除するタスクID |
-| `pushNotificationConfigId` | string | いいえ | 削除するプッシュ通知設定ID(オプション - サーバーはtaskIdから導出可能) |
-| `apiKey` | string | いいえ | 認証用のAPIキー |
-
-#### 出力
-
-| パラメータ | 型 | 説明 |
-| --------- | ---- | ----------- |
-| `success` | boolean | 操作が成功したかどうか |
diff --git a/apps/docs/content/docs/zh/tools/a2a.mdx b/apps/docs/content/docs/zh/tools/a2a.mdx
deleted file mode 100644
index f7c9b55bc28..00000000000
--- a/apps/docs/content/docs/zh/tools/a2a.mdx
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: A2A
-description: 与外部 A2A 兼容代理进行交互
----
-
-import { BlockInfoCard } from "@/components/ui/block-info-card"
-
-
-
-{/* MANUAL-CONTENT-START:intro */}
-A2A(Agent-to-Agent,代理对代理)协议使 Sim 能够与实现了 A2A 兼容 API 的外部 AI 代理和系统进行交互。通过 A2A,您可以将 Sim 的自动化和工作流连接到远程代理——如 LLM 驱动的机器人、微服务和其他基于 AI 的工具——并使用标准化的消息格式进行通信。
-
-在 Sim 中使用 A2A 工具,您可以:
-
-- **向外部代理发送消息**:直接与远程代理通信,提供提示、指令或数据。
-- **接收和流式响应**:在任务进行过程中,从代理获取结构化响应、产物或实时更新。
-- **继续对话或任务**:通过引用任务和上下文 ID,持续多轮对话或工作流。
-- **集成第三方 AI 与自动化**:将外部 A2A 兼容服务作为 Sim 工作流的一部分进行集成。
-
-这些功能让您能够构建高级工作流,将 Sim 的原生能力与外部 AI 或自定义代理的智能和自动化相结合。要使用 A2A 集成,您需要外部代理的 endpoint URL,如果需要,还需提供 API key 或凭证。
-{/* MANUAL-CONTENT-END */}
-
-## 使用说明
-
-使用 A2A(Agent-to-Agent,代理对代理)协议与外部 AI 代理进行交互。
-
-## 工具
-
-### `a2a_send_message`
-
-向外部 A2A 兼容代理发送消息。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 说明 |
-| --------- | ---- | -------- | ----------- |
-| `agentUrl` | string | 是 | A2A 代理 endpoint URL |
-| `message` | string | 是 | 发送给代理的消息 |
-| `taskId` | string | 否 | 用于继续现有任务的 Task ID |
-| `contextId` | string | 否 | 用于对话连续性的 Context ID |
-| `data` | string | 否 | 随消息附带的结构化数据(JSON 字符串) |
-| `files` | array | 否 | 随消息附带的文件 |
-| `apiKey` | string | 否 | 用于身份验证的 API key |
-
-#### 输出
-
-| 参数 | 类型 | 描述 |
-| --------- | ---- | ----------- |
-| `content` | string | 来自 agent 的文本响应内容 |
-| `taskId` | string | 任务唯一标识符 |
-| `contextId` | string | 相关任务/消息的分组 |
-| `state` | string | 当前生命周期状态(working、completed、failed、canceled、rejected、input_required、auth_required) |
-| `artifacts` | array | 任务输出产物 |
-| `history` | array | 会话历史(消息数组) |
-
-### `a2a_get_task`
-
-查询现有 A2A 任务的状态。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 描述 |
-| --------- | ---- | ------ | ----------- |
-| `agentUrl` | string | 是 | A2A agent 端点 URL |
-| `taskId` | string | 是 | 要查询的任务 ID |
-| `apiKey` | string | 否 | 用于身份验证的 API key |
-| `historyLength` | number | 否 | 要包含的历史消息数量 |
-
-#### 输出
-
-| 参数 | 类型 | 描述 |
-| --------- | ---- | ----------- |
-| `taskId` | string | 任务唯一标识符 |
-| `contextId` | string | 相关任务/消息的分组 |
-| `state` | string | 当前生命周期状态(working、completed、failed、canceled、rejected、input_required、auth_required) |
-| `artifacts` | array | 任务输出产物 |
-| `history` | array | 会话历史(消息数组) |
-
-### `a2a_cancel_task`
-
-取消正在运行的 A2A 任务。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 描述 |
-| --------- | ---- | ------ | ----------- |
-| `agentUrl` | string | 是 | A2A agent 端点 URL |
-| `taskId` | string | 是 | 要取消的任务 ID |
-| `apiKey` | string | 否 | 用于身份验证的 API key |
-
-#### 输出
-
-| 参数 | 类型 | 描述 |
-| --------- | ---- | ----------- |
-| `cancelled` | boolean | 是否取消成功 |
-| `state` | string | 当前生命周期状态(working、completed、failed、canceled、rejected、input_required、auth_required) |
-
-### `a2a_get_agent_card`
-
-获取 A2A agent 的 Agent Card(发现文档)。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 描述 |
-| --------- | ---- | ---- | ----------- |
-| `agentUrl` | string | 是 | A2A agent 端点 URL |
-| `apiKey` | string | 否 | 用于认证的 API key(如需) |
-
-#### 输出
-
-| 参数 | 类型 | 描述 |
-| --------- | ---- | ----------- |
-| `name` | string | Agent 显示名称 |
-| `description` | string | Agent 目的/能力 |
-| `url` | string | 服务端点 URL |
-| `provider` | object | 创建组织详情 |
-| `capabilities` | object | 功能支持矩阵 |
-| `skills` | array | 可用操作 |
-| `version` | string | Agent 支持的 A2A 协议版本 |
-| `defaultInputModes` | array | Agent 默认接受的输入内容类型 |
-| `defaultOutputModes` | array | Agent 默认输出的内容类型 |
-
-### `a2a_resubscribe`
-
-在连接中断后,重新连接到正在进行的 A2A 任务流。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 描述 |
-| --------- | ---- | ---- | ----------- |
-| `agentUrl` | string | 是 | A2A agent 端点 URL |
-| `taskId` | string | 是 | 要重新订阅的任务 ID |
-| `apiKey` | string | 否 | 用于认证的 API key |
-
-#### 输出
-
-| 参数 | 类型 | 描述 |
-| --------- | ---- | ----------- |
-| `taskId` | string | 任务唯一标识符 |
-| `contextId` | string | 相关任务/消息的分组 |
-| `state` | string | 当前生命周期状态(working、completed、failed、canceled、rejected、input_required、auth_required) |
-| `isRunning` | boolean | 任务是否仍在运行 |
-| `artifacts` | array | 任务输出产物 |
-| `history` | array | 会话历史(消息数组) |
-
-### `a2a_set_push_notification`
-
-配置 webhook 以接收任务更新通知。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 描述 |
-| --------- | ---- | ---- | ----------- |
-| `agentUrl` | string | 是 | A2A agent 端点 URL |
-| `taskId` | string | 是 | 要配置通知的任务 ID |
-| `webhookUrl` | string | 是 | 用于接收通知的 HTTPS webhook URL |
-| `token` | string | 否 | webhook 验证用的令牌 |
-| `apiKey` | string | 否 | 身份验证用 API key |
-
-#### 输出
-
-| 参数 | 类型 | 描述 |
-| --------- | ---- | ----------- |
-| `url` | string | 用于通知的 HTTPS webhook URL |
-| `token` | string | webhook 验证用的身份令牌 |
-| `success` | boolean | 操作是否成功 |
-
-### `a2a_get_push_notification`
-
-获取任务的推送通知 webhook 配置。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 描述 |
-| --------- | ---- | ---- | ----------- |
-| `agentUrl` | string | 是 | A2A agent 端点 URL |
-| `taskId` | string | 是 | 要获取通知配置的任务 ID |
-| `apiKey` | string | 否 | 身份验证用 API key |
-
-#### 输出
-
-| 参数 | 类型 | 说明 |
-| --------- | ---- | ----------- |
-| `token` | string | 用于 webhook 验证的认证令牌 |
-| `exists` | boolean | 资源是否存在 |
-
-### `a2a_delete_push_notification`
-
-删除任务的推送通知 webhook 配置。
-
-#### 输入
-
-| 参数 | 类型 | 必填 | 说明 |
-| --------- | ---- | ------ | ----------- |
-| `agentUrl` | string | 是 | A2A 代理端点 URL |
-| `taskId` | string | 是 | 要删除通知配置的任务 ID |
-| `pushNotificationConfigId` | string | 否 | 要删除的推送通知配置 ID(可选 - 服务器可根据 taskId 推断)|
-| `apiKey` | string | 否 | 用于认证的 API key |
-
-#### 输出
-
-| 参数 | 类型 | 说明 |
-| --------- | ---- | ----------- |
-| `success` | boolean | 操作是否成功 |
diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts
index 5c91879d91f..a322697fbd4 100644
--- a/apps/docs/next.config.ts
+++ b/apps/docs/next.config.ts
@@ -5,6 +5,7 @@ const withMDX = createMDX()
const config: NextConfig = {
reactStrictMode: true,
+ transpilePackages: ['@sim/emcn', '@sim/workflow-renderer'],
images: {
unoptimized: true,
},
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 9a575cd7105..9f8d622f6cc 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -18,6 +18,8 @@
"@ai-sdk/openai": "2.0.107",
"@ai-sdk/react": "2.0.205",
"@sim/db": "workspace:*",
+ "@sim/emcn": "workspace:*",
+ "@sim/workflow-renderer": "workspace:*",
"@vercel/og": "^0.6.5",
"ai": "5.0.203",
"class-variance-authority": "^0.7.1",
@@ -33,6 +35,7 @@
"postgres": "^3.4.5",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "remark-breaks": "^4.0.0",
"shiki": "4.0.0",
"streamdown": "2.5.0",
"tailwind-merge": "^3.0.2",
diff --git a/apps/pii/server.py b/apps/pii/server.py
index 597fe8f3d90..3fbd9859e45 100644
--- a/apps/pii/server.py
+++ b/apps/pii/server.py
@@ -5,6 +5,8 @@
endpoints so a single PRESIDIO_URL serves both.
"""
+import logging
+import time
from typing import Any
from fastapi import FastAPI
@@ -133,6 +135,9 @@ def build_analyzer() -> AnalyzerEngine:
analyzer = build_analyzer()
anonymizer = AnonymizerEngine()
+# Propagates to uvicorn's root handler, so timing lands in the container log stream.
+logger = logging.getLogger("sim.pii")
+
app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None)
@@ -163,6 +168,7 @@ def supported_entities(language: str = "en") -> list[str]:
@app.post("/analyze")
def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
+ started = time.perf_counter()
results = analyzer.analyze(
text=req.text,
language=req.language,
@@ -170,11 +176,19 @@ def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
score_threshold=req.score_threshold,
return_decision_process=req.return_decision_process,
)
+ logger.info(
+ "analyze lang=%s chars=%d entities=%d duration_ms=%.1f",
+ req.language,
+ len(req.text),
+ len(results),
+ (time.perf_counter() - started) * 1000,
+ )
return [r.to_dict() for r in results]
@app.post("/anonymize")
def anonymize(req: AnonymizeRequest) -> dict[str, Any]:
+ started = time.perf_counter()
analyzer_results = [
RecognizerResult(
entity_type=r["entity_type"],
@@ -197,6 +211,12 @@ def anonymize(req: AnonymizeRequest) -> dict[str, Any]:
analyzer_results=analyzer_results,
operators=operators,
)
+ logger.info(
+ "anonymize chars=%d spans=%d duration_ms=%.1f",
+ len(req.text),
+ len(analyzer_results),
+ (time.perf_counter() - started) * 1000,
+ )
return {
"text": result.text,
"items": [
diff --git a/apps/sim/app/(auth)/components/auth-background.tsx b/apps/sim/app/(auth)/components/auth-background.tsx
index dc284dbd66e..fedca77f536 100644
--- a/apps/sim/app/(auth)/components/auth-background.tsx
+++ b/apps/sim/app/(auth)/components/auth-background.tsx
@@ -1,4 +1,4 @@
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
import AuthBackgroundSVG from '@/app/(auth)/components/auth-background-svg'
type AuthBackgroundProps = {
diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx
index ed46b9413aa..feaf4889940 100644
--- a/apps/sim/app/(auth)/components/social-login-buttons.tsx
+++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx
@@ -1,7 +1,7 @@
'use client'
import { type ReactNode, useState } from 'react'
-import { Button } from '@/components/emcn'
+import { Button } from '@sim/emcn'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { client } from '@/lib/auth/auth-client'
diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx
index d3f50852e91..851fd1bf993 100644
--- a/apps/sim/app/(auth)/components/sso-login-button.tsx
+++ b/apps/sim/app/(auth)/components/sso-login-button.tsx
@@ -1,9 +1,7 @@
'use client'
-
+import { Button, cn } from '@sim/emcn'
import { useRouter } from 'next/navigation'
-import { Button } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
-import { cn } from '@/lib/core/utils/cn'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
interface SSOLoginButtonProps {
diff --git a/apps/sim/app/(auth)/login/loading.tsx b/apps/sim/app/(auth)/login/loading.tsx
index c21272e110d..74f7bece6c9 100644
--- a/apps/sim/app/(auth)/login/loading.tsx
+++ b/apps/sim/app/(auth)/login/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function LoginLoading() {
return (
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx
index de314167c7e..8f102308bc0 100644
--- a/apps/sim/app/(auth)/login/login-form.tsx
+++ b/apps/sim/app/(auth)/login/login-form.tsx
@@ -1,11 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { Eye, EyeOff } from 'lucide-react'
-import Link from 'next/link'
-import { useRouter, useSearchParams } from 'next/navigation'
import {
ChipModal,
ChipModalBody,
@@ -13,16 +8,21 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ cn,
Input,
Label,
Loader,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { Eye, EyeOff } from 'lucide-react'
+import Link from 'next/link'
+import { useRouter, useSearchParams } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { forgetPasswordContract } from '@/lib/api/contracts'
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
-import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent } from '@/lib/posthog/client'
diff --git a/apps/sim/app/(auth)/reset-password/loading.tsx b/apps/sim/app/(auth)/reset-password/loading.tsx
index 02a11005e1b..d1910ac0425 100644
--- a/apps/sim/app/(auth)/reset-password/loading.tsx
+++ b/apps/sim/app/(auth)/reset-password/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function ResetPasswordLoading() {
return (
diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx
index f1627b8397d..301a804d849 100644
--- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx
+++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx
@@ -1,9 +1,8 @@
'use client'
import { useState } from 'react'
+import { cn, Input, Label, Loader } from '@sim/emcn'
import { Eye, EyeOff } from 'lucide-react'
-import { Input, Label, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
interface RequestResetFormProps {
diff --git a/apps/sim/app/(auth)/signup/loading.tsx b/apps/sim/app/(auth)/signup/loading.tsx
index 21e15d9f713..c1190c8385e 100644
--- a/apps/sim/app/(auth)/signup/loading.tsx
+++ b/apps/sim/app/(auth)/signup/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function SignupLoading() {
return (
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index ae73e36cb5a..62be1b98958 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -2,16 +2,15 @@
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
+import { cn, Input, Label, Loader } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
-import { Input, Label, Loader } from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
-import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
diff --git a/apps/sim/app/(auth)/sso/loading.tsx b/apps/sim/app/(auth)/sso/loading.tsx
index 76209c2f50e..116e47c136b 100644
--- a/apps/sim/app/(auth)/sso/loading.tsx
+++ b/apps/sim/app/(auth)/sso/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function SSOLoading() {
return (
diff --git a/apps/sim/app/(auth)/verify/loading.tsx b/apps/sim/app/(auth)/verify/loading.tsx
index 7460e3295a9..d884048905a 100644
--- a/apps/sim/app/(auth)/verify/loading.tsx
+++ b/apps/sim/app/(auth)/verify/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function VerifyLoading() {
return (
diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx
index 284af360a11..11e68bbf32a 100644
--- a/apps/sim/app/(auth)/verify/verify-content.tsx
+++ b/apps/sim/app/(auth)/verify/verify-content.tsx
@@ -1,9 +1,8 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
+import { cn, InputOTP, InputOTPGroup, InputOTPSlot, Loader } from '@sim/emcn'
import { useRouter } from 'next/navigation'
-import { InputOTP, InputOTPGroup, InputOTPSlot, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { useVerification } from '@/app/(auth)/verify/use-verification'
diff --git a/apps/sim/app/(landing)/blog/[slug]/loading.tsx b/apps/sim/app/(landing)/blog/[slug]/loading.tsx
index b8bfdcdbc4d..a9dffd0957e 100644
--- a/apps/sim/app/(landing)/blog/[slug]/loading.tsx
+++ b/apps/sim/app/(landing)/blog/[slug]/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function BlogPostLoading() {
return (
diff --git a/apps/sim/app/(landing)/blog/[slug]/page.tsx b/apps/sim/app/(landing)/blog/[slug]/page.tsx
index cce6ee8dfb4..750bbed5126 100644
--- a/apps/sim/app/(landing)/blog/[slug]/page.tsx
+++ b/apps/sim/app/(landing)/blog/[slug]/page.tsx
@@ -1,7 +1,7 @@
+import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
diff --git a/apps/sim/app/(landing)/blog/[slug]/share-button.tsx b/apps/sim/app/(landing)/blog/[slug]/share-button.tsx
index 679c6f44c6e..8372db5a55c 100644
--- a/apps/sim/app/(landing)/blog/[slug]/share-button.tsx
+++ b/apps/sim/app/(landing)/blog/[slug]/share-button.tsx
@@ -1,14 +1,9 @@
'use client'
import { useState } from 'react'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
+import { Duplicate } from '@sim/emcn/icons'
import { Share2 } from 'lucide-react'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Duplicate } from '@/components/emcn/icons'
import { LinkedInIcon, xIcon as XIcon } from '@/components/icons'
interface ShareButtonProps {
diff --git a/apps/sim/app/(landing)/blog/authors/[id]/loading.tsx b/apps/sim/app/(landing)/blog/authors/[id]/loading.tsx
index e3bbc060f9d..a1da67b964e 100644
--- a/apps/sim/app/(landing)/blog/authors/[id]/loading.tsx
+++ b/apps/sim/app/(landing)/blog/authors/[id]/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
const SKELETON_POST_COUNT = 4
diff --git a/apps/sim/app/(landing)/blog/components/blog-image.tsx b/apps/sim/app/(landing)/blog/components/blog-image.tsx
index 84be2e6b2f5..bbb41e22cc4 100644
--- a/apps/sim/app/(landing)/blog/components/blog-image.tsx
+++ b/apps/sim/app/(landing)/blog/components/blog-image.tsx
@@ -1,8 +1,8 @@
'use client'
import { useState } from 'react'
+import { cn } from '@sim/emcn'
import NextImage from 'next/image'
-import { cn } from '@/lib/core/utils/cn'
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
interface BlogImageProps {
diff --git a/apps/sim/app/(landing)/blog/loading.tsx b/apps/sim/app/(landing)/blog/loading.tsx
index 616fa609709..d7a7e4a2f3a 100644
--- a/apps/sim/app/(landing)/blog/loading.tsx
+++ b/apps/sim/app/(landing)/blog/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function BlogLoading() {
return (
diff --git a/apps/sim/app/(landing)/blog/page.tsx b/apps/sim/app/(landing)/blog/page.tsx
index 417fc0b276b..56e40341ee8 100644
--- a/apps/sim/app/(landing)/blog/page.tsx
+++ b/apps/sim/app/(landing)/blog/page.tsx
@@ -1,7 +1,7 @@
+import { Badge } from '@sim/emcn'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
-import { Badge } from '@/components/emcn'
import { getAllPostMeta } from '@/lib/blog/registry'
import { buildCollectionPageJsonLd } from '@/lib/blog/seo'
import { SITE_URL } from '@/lib/core/utils/urls'
diff --git a/apps/sim/app/(landing)/blog/tags/loading.tsx b/apps/sim/app/(landing)/blog/tags/loading.tsx
index 9d47cdc8062..358a577dceb 100644
--- a/apps/sim/app/(landing)/blog/tags/loading.tsx
+++ b/apps/sim/app/(landing)/blog/tags/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
const SKELETON_TAG_COUNT = 12
diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
index d0a1a985ac0..501b40cd408 100644
--- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
+++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
@@ -1,10 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { X } from 'lucide-react'
-import Image from 'next/image'
-import { useRouter } from 'next/navigation'
import {
Loader,
Modal,
@@ -13,7 +9,11 @@ import {
ModalDescription,
ModalTitle,
ModalTrigger,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { X } from 'lucide-react'
+import Image from 'next/image'
+import { useRouter } from 'next/navigation'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth'
diff --git a/apps/sim/app/(landing)/components/collaboration/collaboration.tsx b/apps/sim/app/(landing)/components/collaboration/collaboration.tsx
index 2667c14a3df..52ee20bcc56 100644
--- a/apps/sim/app/(landing)/components/collaboration/collaboration.tsx
+++ b/apps/sim/app/(landing)/components/collaboration/collaboration.tsx
@@ -1,10 +1,10 @@
'use client'
import { useCallback, useId, useRef, useState } from 'react'
+import { Badge } from '@sim/emcn'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
-import { Badge } from '@/components/emcn'
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
const AuthModal = dynamic(
diff --git a/apps/sim/app/(landing)/components/contact/contact-form.tsx b/apps/sim/app/(landing)/components/contact/contact-form.tsx
index b866b9e42cd..4f351884fd0 100644
--- a/apps/sim/app/(landing)/components/contact/contact-form.tsx
+++ b/apps/sim/app/(landing)/components/contact/contact-form.tsx
@@ -2,11 +2,11 @@
import { useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
+import { Combobox, Input, Textarea } from '@sim/emcn'
+import { Check } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { useMutation } from '@tanstack/react-query'
import Link from 'next/link'
-import { Combobox, Input, Textarea } from '@/components/emcn'
-import { Check } from '@/components/emcn/icons'
import { requestJson } from '@/lib/api/client/request'
import {
CONTACT_TOPIC_OPTIONS,
diff --git a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx
index 755f5eb9911..cacef962fd7 100644
--- a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx
+++ b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useState } from 'react'
-import { getErrorMessage } from '@sim/utils/errors'
-import { useMutation } from '@tanstack/react-query'
import {
ChipCombobox,
ChipInput,
@@ -14,8 +12,10 @@ import {
ModalFooter,
ModalHeader,
ModalTrigger,
-} from '@/components/emcn'
-import { Check } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Check } from '@sim/emcn/icons'
+import { getErrorMessage } from '@sim/utils/errors'
+import { useMutation } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
diff --git a/apps/sim/app/(landing)/components/features/components/features-preview.tsx b/apps/sim/app/(landing)/components/features/components/features-preview.tsx
index 1db87f478e2..a1a5af32264 100644
--- a/apps/sim/app/(landing)/components/features/components/features-preview.tsx
+++ b/apps/sim/app/(landing)/components/features/components/features-preview.tsx
@@ -4,8 +4,8 @@ import { type SVGProps, useEffect, useRef, useState } from 'react'
import { AnimatePresence, domAnimation, LazyMotion, m, useInView } from 'framer-motion'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
-import { ChevronDown } from '@/components/emcn'
-import { Database, File, Library, Table, Workflow } from '@/components/emcn/icons'
+import { ChevronDown, cn } from '@sim/emcn'
+import { Database, File, Library, Table, Workflow } from '@sim/emcn/icons'
import {
AnthropicIcon,
GeminiIcon,
@@ -17,7 +17,6 @@ import {
SlackIcon,
xAIIcon,
} from '@/components/icons'
-import { cn } from '@/lib/core/utils/cn'
interface FeaturesPreviewProps {
activeTab: number
diff --git a/apps/sim/app/(landing)/components/features/features.tsx b/apps/sim/app/(landing)/components/features/features.tsx
index abca2c74d9f..350d868d262 100644
--- a/apps/sim/app/(landing)/components/features/features.tsx
+++ b/apps/sim/app/(landing)/components/features/features.tsx
@@ -1,6 +1,7 @@
'use client'
import { useRef, useState } from 'react'
+import { Badge } from '@sim/emcn'
import {
domAnimation,
LazyMotion,
@@ -11,7 +12,6 @@ import {
} from 'framer-motion'
import dynamic from 'next/dynamic'
import Image from 'next/image'
-import { Badge } from '@/components/emcn'
import { FeaturesPreview } from '@/app/(landing)/components/features/components/features-preview'
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
diff --git a/apps/sim/app/(landing)/components/footer/footer-cta.tsx b/apps/sim/app/(landing)/components/footer/footer-cta.tsx
index 31355a75f35..c28b76ade4c 100644
--- a/apps/sim/app/(landing)/components/footer/footer-cta.tsx
+++ b/apps/sim/app/(landing)/components/footer/footer-cta.tsx
@@ -1,10 +1,9 @@
'use client'
import { useCallback, useRef, useState } from 'react'
+import { cn, handleKeyboardActivation } from '@sim/emcn'
import { ArrowUp } from 'lucide-react'
import dynamic from 'next/dynamic'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { captureClientEvent } from '@/lib/posthog/client'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx
index 9e06300511c..723d9456a00 100644
--- a/apps/sim/app/(landing)/components/footer/footer.tsx
+++ b/apps/sim/app/(landing)/components/footer/footer.tsx
@@ -1,6 +1,6 @@
+import { cn } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
-import { cn } from '@/lib/core/utils/cn'
import { FooterCTA } from '@/app/(landing)/components/footer/footer-cta'
const LINK_CLASS =
diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx
index 512630ecb1a..193795a4d2c 100644
--- a/apps/sim/app/(landing)/components/hero/hero.tsx
+++ b/apps/sim/app/(landing)/components/hero/hero.tsx
@@ -1,7 +1,7 @@
'use client'
+import { cn } from '@sim/emcn'
import dynamic from 'next/dynamic'
-import { cn } from '@/lib/core/utils/cn'
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
const AuthModal = dynamic(
diff --git a/apps/sim/app/(landing)/components/landing-faq.tsx b/apps/sim/app/(landing)/components/landing-faq.tsx
index 3cebc334081..bd8afbafecd 100644
--- a/apps/sim/app/(landing)/components/landing-faq.tsx
+++ b/apps/sim/app/(landing)/components/landing-faq.tsx
@@ -1,9 +1,8 @@
'use client'
import { useId, useState } from 'react'
+import { ChevronDown, cn } from '@sim/emcn'
import { domAnimation, LazyMotion, m } from 'framer-motion'
-import { ChevronDown } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
interface LandingFAQItem {
question: string
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx
index 8d3ecc0a309..e481772ac5f 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx
@@ -1,4 +1,4 @@
-import { File } from '@/components/emcn/icons'
+import { File } from '@sim/emcn/icons'
import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
import type {
PreviewColumn,
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx
index 249ddaea85f..bc46cc13e33 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx
@@ -1,10 +1,10 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
+import { Blimp, Checkbox, ChevronDown } from '@sim/emcn'
+import { TypeBoolean, TypeNumber, TypeText } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import { ArrowUp, Table } from 'lucide-react'
-import { Blimp, Checkbox, ChevronDown } from '@/components/emcn'
-import { TypeBoolean, TypeNumber, TypeText } from '@/components/emcn/icons'
import { captureClientEvent } from '@/lib/posthog/client'
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge.tsx
index 8bfa219ec41..64d18fdfb71 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge.tsx
@@ -1,4 +1,4 @@
-import { Database } from '@/components/emcn/icons'
+import { Database } from '@sim/emcn/icons'
import {
AirtableIcon,
AsanaIcon,
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs.tsx
index b20c7fba7b3..99cace4497c 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs.tsx
@@ -1,10 +1,9 @@
'use client'
import { useMemo, useState } from 'react'
-import { ArrowUpDown, Badge, Library, ListFilter, Search } from '@/components/emcn'
-import type { BadgeProps } from '@/components/emcn/components/badge/badge'
-import { Download, Workflow } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+import type { BadgeProps } from '@sim/emcn'
+import { ArrowUpDown, Badge, cn, Library, ListFilter, Search } from '@sim/emcn'
+import { Download, Workflow } from '@sim/emcn/icons'
interface LogRow {
id: string
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx
index b421f05fcb4..fa744b9bde7 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx
@@ -1,12 +1,12 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
+import { Blimp, BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@sim/emcn'
import { AnimatePresence, domMax, LazyMotion, m } from 'framer-motion'
import { ArrowUp } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { createPortal } from 'react-dom'
-import { Blimp, BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
import { AgentIcon, HubspotIcon, OpenAIIcon, SalesforceIcon } from '@/components/icons'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { captureClientEvent } from '@/lib/posthog/client'
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource.tsx
index 4ed7728f3ab..a726f053298 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource.tsx
@@ -2,8 +2,7 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
-import { ArrowUpDown, ListFilter, Plus, Search } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+import { ArrowUpDown, cn, ListFilter, Plus, Search } from '@sim/emcn'
export interface PreviewColumn {
id: string
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks.tsx
index 0519cc8b7c4..c512f3985bc 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks.tsx
@@ -1,4 +1,4 @@
-import { Calendar } from '@/components/emcn/icons'
+import { Calendar } from '@sim/emcn/icons'
import type {
PreviewColumn,
PreviewRow,
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx
index 85f12f9bd00..5cd946007a1 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx
@@ -1,6 +1,5 @@
'use client'
-
-import { ChevronDown, Home, Library } from '@/components/emcn'
+import { ChevronDown, cn, Home, Library } from '@sim/emcn'
import {
Calendar,
Database,
@@ -10,8 +9,7 @@ import {
Settings,
Table,
Workflow,
-} from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn/icons'
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
export type SidebarView =
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx
index 4d9c5bcef30..7e7fa86e727 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx
@@ -1,8 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
-import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
-import { Checkbox } from '@/components/emcn'
+import { Checkbox, cn } from '@sim/emcn'
import {
ChevronDown,
Columns3,
@@ -11,8 +10,8 @@ import {
TypeBoolean,
TypeNumber,
TypeText,
-} from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn/icons'
+import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import type {
PreviewColumn,
PreviewRow,
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx
index 0e2af73d5da..0c9d9612bde 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx
@@ -1,10 +1,10 @@
'use client'
import { memo } from 'react'
+import { Blimp } from '@sim/emcn'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { Database } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
-import { Blimp } from '@/components/emcn'
import {
AgentIcon,
AnthropicIcon,
diff --git a/apps/sim/app/(landing)/components/navbar/components/blog-dropdown.tsx b/apps/sim/app/(landing)/components/navbar/components/blog-dropdown.tsx
index dca4a4c131f..cb45223479e 100644
--- a/apps/sim/app/(landing)/components/navbar/components/blog-dropdown.tsx
+++ b/apps/sim/app/(landing)/components/navbar/components/blog-dropdown.tsx
@@ -1,6 +1,6 @@
+import { cn } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
-import { cn } from '@/lib/core/utils/cn'
export interface NavBlogPost {
slug: string
diff --git a/apps/sim/app/(landing)/components/navbar/navbar.tsx b/apps/sim/app/(landing)/components/navbar/navbar.tsx
index aade5db8c8d..c5d76a2e0d2 100644
--- a/apps/sim/app/(landing)/components/navbar/navbar.tsx
+++ b/apps/sim/app/(landing)/components/navbar/navbar.tsx
@@ -1,11 +1,11 @@
'use client'
import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react'
+import { cn } from '@sim/emcn'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
import { GithubOutlineIcon } from '@/components/icons'
-import { cn } from '@/lib/core/utils/cn'
import { SessionContext } from '@/app/_shell/providers/session-provider'
import {
BlogDropdown,
diff --git a/apps/sim/app/(landing)/components/pricing/pricing.tsx b/apps/sim/app/(landing)/components/pricing/pricing.tsx
index b3d417ffba0..ebd906bcfaf 100644
--- a/apps/sim/app/(landing)/components/pricing/pricing.tsx
+++ b/apps/sim/app/(landing)/components/pricing/pricing.tsx
@@ -1,7 +1,7 @@
'use client'
+import { Badge } from '@sim/emcn'
import dynamic from 'next/dynamic'
-import { Badge } from '@/components/emcn'
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
const AuthModal = dynamic(
diff --git a/apps/sim/app/(landing)/components/templates/templates.tsx b/apps/sim/app/(landing)/components/templates/templates.tsx
index d8acf137c94..33ffccfb3e4 100644
--- a/apps/sim/app/(landing)/components/templates/templates.tsx
+++ b/apps/sim/app/(landing)/components/templates/templates.tsx
@@ -1,13 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
+import { Badge, ChevronDown, cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
-import { Badge, ChevronDown } from '@/components/emcn'
import { LandingWorkflowSeedStorage } from '@/lib/core/utils/browser-storage'
-import { cn } from '@/lib/core/utils/cn'
import { TEMPLATE_WORKFLOWS } from '@/app/(landing)/components/templates/template-workflows'
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/template-card-button.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/template-card-button.tsx
index b7d8dc85593..ee562d80d4b 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/template-card-button.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/template-card-button.tsx
@@ -1,8 +1,8 @@
'use client'
+import { cn } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
-import { cn } from '@/lib/core/utils/cn'
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
interface TemplateCardButtonProps {
diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/loading.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/loading.tsx
index f2c2fb44d6e..79d9305ca4c 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/loading.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/loading.tsx
@@ -1,4 +1,4 @@
-import { Loader } from '@/components/emcn'
+import { Loader } from '@sim/emcn'
export default function IntegrationDetailLoading() {
return (
diff --git a/apps/sim/app/(landing)/integrations/(shell)/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/page.tsx
index b311e3f315f..7a6df9f5e4e 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/page.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/page.tsx
@@ -1,5 +1,5 @@
+import { Badge } from '@sim/emcn'
import type { Metadata } from 'next'
-import { Badge } from '@/components/emcn'
import { SITE_URL } from '@/lib/core/utils/urls'
import {
blockTypeToIconMap,
diff --git a/apps/sim/app/(landing)/integrations/[slug]/loading.tsx b/apps/sim/app/(landing)/integrations/[slug]/loading.tsx
index f2c2fb44d6e..79d9305ca4c 100644
--- a/apps/sim/app/(landing)/integrations/[slug]/loading.tsx
+++ b/apps/sim/app/(landing)/integrations/[slug]/loading.tsx
@@ -1,4 +1,4 @@
-import { Loader } from '@/components/emcn'
+import { Loader } from '@sim/emcn'
export default function IntegrationDetailLoading() {
return (
diff --git a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
index b1f3b0bb547..5e2225dc866 100644
--- a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
+++ b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { ChipInput, Search } from '@/components/emcn'
+import { ChipInput, Search } from '@sim/emcn'
import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card'
diff --git a/apps/sim/app/(landing)/integrations/components/integration-icon.tsx b/apps/sim/app/(landing)/integrations/components/integration-icon.tsx
index fb9c1219419..ec1b7932740 100644
--- a/apps/sim/app/(landing)/integrations/components/integration-icon.tsx
+++ b/apps/sim/app/(landing)/integrations/components/integration-icon.tsx
@@ -1,5 +1,5 @@
import type { ComponentType, ElementType, HTMLAttributes, SVGProps } from 'react'
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
interface IntegrationIconProps extends HTMLAttributes
{
bgColor: string
diff --git a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx
index d8fd42e9410..e6634ee275e 100644
--- a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx
+++ b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx
@@ -8,7 +8,7 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
-} from '@/components/emcn'
+} from '@sim/emcn'
import { requestJson } from '@/lib/api/client/request'
import { integrationRequestContract } from '@/lib/api/contracts/common'
diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/loading.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/loading.tsx
index 30b4f6216f6..87c2a835b23 100644
--- a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/loading.tsx
+++ b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/loading.tsx
@@ -1,4 +1,4 @@
-import { Loader } from '@/components/emcn'
+import { Loader } from '@sim/emcn'
export default function ModelDetailLoading() {
return (
diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/loading.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/loading.tsx
index b6afc017c9c..47f35d2b2be 100644
--- a/apps/sim/app/(landing)/models/(shell)/[provider]/loading.tsx
+++ b/apps/sim/app/(landing)/models/(shell)/[provider]/loading.tsx
@@ -1,4 +1,4 @@
-import { Loader } from '@/components/emcn'
+import { Loader } from '@sim/emcn'
export default function ModelProviderLoading() {
return (
diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx
index 76139ce8421..e5e44ed0910 100644
--- a/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx
+++ b/apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx
@@ -1,7 +1,7 @@
+import { Badge } from '@sim/emcn'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
-import { Badge } from '@/components/emcn'
import { SITE_URL } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import {
diff --git a/apps/sim/app/(landing)/models/(shell)/page.tsx b/apps/sim/app/(landing)/models/(shell)/page.tsx
index 002237c6feb..a324245c4c2 100644
--- a/apps/sim/app/(landing)/models/(shell)/page.tsx
+++ b/apps/sim/app/(landing)/models/(shell)/page.tsx
@@ -1,5 +1,5 @@
+import { Badge } from '@sim/emcn'
import type { Metadata } from 'next'
-import { Badge } from '@/components/emcn'
import { SITE_URL } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import { ModelComparisonCharts } from '@/app/(landing)/models/components/model-comparison-charts'
diff --git a/apps/sim/app/(landing)/models/[provider]/[model]/loading.tsx b/apps/sim/app/(landing)/models/[provider]/[model]/loading.tsx
index 30b4f6216f6..87c2a835b23 100644
--- a/apps/sim/app/(landing)/models/[provider]/[model]/loading.tsx
+++ b/apps/sim/app/(landing)/models/[provider]/[model]/loading.tsx
@@ -1,4 +1,4 @@
-import { Loader } from '@/components/emcn'
+import { Loader } from '@sim/emcn'
export default function ModelDetailLoading() {
return (
diff --git a/apps/sim/app/(landing)/models/[provider]/loading.tsx b/apps/sim/app/(landing)/models/[provider]/loading.tsx
index b6afc017c9c..47f35d2b2be 100644
--- a/apps/sim/app/(landing)/models/[provider]/loading.tsx
+++ b/apps/sim/app/(landing)/models/[provider]/loading.tsx
@@ -1,4 +1,4 @@
-import { Loader } from '@/components/emcn'
+import { Loader } from '@sim/emcn'
export default function ModelProviderLoading() {
return (
diff --git a/apps/sim/app/(landing)/models/components/model-directory.tsx b/apps/sim/app/(landing)/models/components/model-directory.tsx
index c0c1c46f44c..a55573f93be 100644
--- a/apps/sim/app/(landing)/models/components/model-directory.tsx
+++ b/apps/sim/app/(landing)/models/components/model-directory.tsx
@@ -1,8 +1,8 @@
'use client'
import { useMemo, useState } from 'react'
+import { Input } from '@sim/emcn'
import Link from 'next/link'
-import { Input } from '@/components/emcn'
import { ChevronArrow, ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
import {
type CatalogModel,
diff --git a/apps/sim/app/(landing)/models/components/model-primitives.tsx b/apps/sim/app/(landing)/models/components/model-primitives.tsx
index 56d352a1a57..22cb43182a2 100644
--- a/apps/sim/app/(landing)/models/components/model-primitives.tsx
+++ b/apps/sim/app/(landing)/models/components/model-primitives.tsx
@@ -1,5 +1,5 @@
+import { Badge } from '@sim/emcn'
import Link from 'next/link'
-import { Badge } from '@/components/emcn'
import {
type CatalogModel,
type CatalogProvider,
diff --git a/apps/sim/app/(landing)/privacy/loading.tsx b/apps/sim/app/(landing)/privacy/loading.tsx
index 962c436464f..8f6679ad27a 100644
--- a/apps/sim/app/(landing)/privacy/loading.tsx
+++ b/apps/sim/app/(landing)/privacy/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function PrivacyLoading() {
return (
diff --git a/apps/sim/app/(landing)/terms/loading.tsx b/apps/sim/app/(landing)/terms/loading.tsx
index 90391b4e239..81a8e2f7a87 100644
--- a/apps/sim/app/(landing)/terms/loading.tsx
+++ b/apps/sim/app/(landing)/terms/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function TermsLoading() {
return (
diff --git a/apps/sim/app/_shell/providers/tooltip-provider.tsx b/apps/sim/app/_shell/providers/tooltip-provider.tsx
index 84274ddb8c2..efd659a812c 100644
--- a/apps/sim/app/_shell/providers/tooltip-provider.tsx
+++ b/apps/sim/app/_shell/providers/tooltip-provider.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Tooltip } from '@/components/emcn'
+import { Tooltip } from '@sim/emcn'
interface TooltipProviderProps {
children: React.ReactNode
diff --git a/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx b/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx
index 4dd486620e3..81e88055f63 100644
--- a/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx
+++ b/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx
@@ -1,9 +1,9 @@
'use client'
import { useMemo, useSyncExternalStore } from 'react'
+import { Loader } from '@sim/emcn'
import { CheckCircle2, Circle, ExternalLink, GraduationCap } from 'lucide-react'
import Link from 'next/link'
-import { Loader } from '@/components/emcn'
import {
getCompletedLessonsFromSnapshot,
getCompletedLessonsSnapshot,
diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx
index 045993db67a..c8c7fd6316e 100644
--- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx
+++ b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx
@@ -1,10 +1,10 @@
'use client'
import { useState } from 'react'
+import { cn } from '@sim/emcn'
import { CheckCircle2, XCircle } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
-import { cn } from '@/lib/core/utils/cn'
interface LessonQuizProps {
lessonId: string
diff --git a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx
index 4a283225547..33dfca9ab61 100644
--- a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx
+++ b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
+import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import type { Edge } from 'reactflow'
@@ -12,7 +13,6 @@ import type {
ValidationResult,
} from '@/lib/academy/types'
import { validateExercise } from '@/lib/academy/validation'
-import { cn } from '@/lib/core/utils/cn'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
diff --git a/apps/sim/app/academy/components/validation-checklist.tsx b/apps/sim/app/academy/components/validation-checklist.tsx
index 977da84afaa..2f892f56f6f 100644
--- a/apps/sim/app/academy/components/validation-checklist.tsx
+++ b/apps/sim/app/academy/components/validation-checklist.tsx
@@ -1,8 +1,8 @@
'use client'
+import { cn } from '@sim/emcn'
import { CheckCircle2, Circle } from 'lucide-react'
import type { ValidationRuleResult } from '@/lib/academy/types'
-import { cn } from '@/lib/core/utils/cn'
interface ValidationChecklistProps {
results: ValidationRuleResult[]
diff --git a/apps/sim/app/api/a2a/agents/[agentId]/route.ts b/apps/sim/app/api/a2a/agents/[agentId]/route.ts
deleted file mode 100644
index bf05379f861..00000000000
--- a/apps/sim/app/api/a2a/agents/[agentId]/route.ts
+++ /dev/null
@@ -1,355 +0,0 @@
-import { db } from '@sim/db'
-import { a2aAgent, workflow } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, eq, isNull } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
-import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
-import {
- a2aAgentParamsSchema,
- publishA2AAgentContract,
- updateA2AAgentContract,
-} from '@/lib/api/contracts/a2a-agents'
-import { parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { getRedisClient } from '@/lib/core/config/redis'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { captureServerEvent } from '@/lib/posthog/server'
-import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
-import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
-
-const logger = createLogger('A2AAgentCardAPI')
-
-export const dynamic = 'force-dynamic'
-
-interface RouteParams {
- agentId: string
-}
-
-/**
- * GET - Returns the Agent Card for discovery
- */
-export const GET = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise }) => {
- const { agentId } = a2aAgentParamsSchema.parse(await params)
-
- try {
- const [agent] = await db
- .select({
- agent: a2aAgent,
- workflow: workflow,
- })
- .from(a2aAgent)
- .innerJoin(workflow, and(eq(a2aAgent.workflowId, workflow.id), isNull(workflow.archivedAt)))
- .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
- .limit(1)
-
- if (!agent) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
- }
-
- if (!agent.agent.isPublished) {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
- }
-
- const workspaceAccess = await checkWorkspaceAccess(agent.agent.workspaceId, auth.userId)
- if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
- return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
- }
- }
-
- const agentCard = generateAgentCard(
- {
- id: agent.agent.id,
- name: agent.agent.name,
- description: agent.agent.description,
- version: agent.agent.version,
- capabilities: agent.agent.capabilities as AgentCapabilities,
- skills: agent.agent.skills as AgentSkill[],
- },
- {
- id: agent.workflow.id,
- name: agent.workflow.name,
- description: agent.workflow.description,
- }
- )
-
- return NextResponse.json(agentCard, {
- headers: {
- 'Content-Type': 'application/json',
- 'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
- },
- })
- } catch (error) {
- logger.error('Error getting Agent Card:', error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-/**
- * PUT - Update an agent
- */
-export const PUT = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise }) => {
- const { agentId } = a2aAgentParamsSchema.parse(await params)
-
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const [existingAgent] = await db
- .select()
- .from(a2aAgent)
- .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
- .limit(1)
-
- if (!existingAgent) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
- }
-
- const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
- if (!workspaceAccess.canWrite) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- const parsed = await parseRequest(updateA2AAgentContract, request, { params })
- if (!parsed.success) return parsed.response
- const body = parsed.data.body
-
- let skills = body.skills ?? existingAgent.skills
- if (body.skillTags !== undefined) {
- const agentName = body.name ?? existingAgent.name
- const agentDescription = body.description ?? existingAgent.description
- skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags)
- }
-
- const [updatedAgent] = await db
- .update(a2aAgent)
- .set({
- name: body.name ?? existingAgent.name,
- description: body.description ?? existingAgent.description,
- version: body.version ?? existingAgent.version,
- capabilities: body.capabilities ?? existingAgent.capabilities,
- skills,
- authentication: body.authentication ?? existingAgent.authentication,
- isPublished: body.isPublished ?? existingAgent.isPublished,
- publishedAt:
- body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
- updatedAt: new Date(),
- })
- .where(eq(a2aAgent.id, agentId))
- .returning()
-
- logger.info(`Updated A2A agent: ${agentId}`)
-
- return NextResponse.json({ success: true, agent: updatedAgent })
- } catch (error) {
- logger.error('Error updating agent:', error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-/**
- * DELETE - Delete an agent
- */
-export const DELETE = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise }) => {
- const { agentId } = a2aAgentParamsSchema.parse(await params)
-
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const [existingAgent] = await db
- .select()
- .from(a2aAgent)
- .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
- .limit(1)
-
- if (!existingAgent) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
- }
-
- const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
- if (!workspaceAccess.canWrite) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
-
- logger.info(`Deleted A2A agent: ${agentId}`)
-
- captureServerEvent(
- auth.userId,
- 'a2a_agent_deleted',
- {
- agent_id: agentId,
- workflow_id: existingAgent.workflowId,
- workspace_id: existingAgent.workspaceId,
- },
- { groups: { workspace: existingAgent.workspaceId } }
- )
-
- return NextResponse.json({ success: true })
- } catch (error) {
- logger.error('Error deleting agent:', error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-/**
- * POST - Publish/unpublish an agent
- */
-export const POST = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise }) => {
- const { agentId } = a2aAgentParamsSchema.parse(await params)
-
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- logger.warn('A2A agent publish auth failed:', {
- error: auth.error,
- hasUserId: !!auth.userId,
- })
- return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
- }
-
- const [existingAgent] = await db
- .select()
- .from(a2aAgent)
- .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
- .limit(1)
-
- if (!existingAgent) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
- }
-
- const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
- if (!workspaceAccess.canWrite) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- const parsed = await parseRequest(publishA2AAgentContract, request, { params })
- if (!parsed.success) return parsed.response
- const { action } = parsed.data.body
-
- if (action === 'publish') {
- const [wf] = await db
- .select({ isDeployed: workflow.isDeployed })
- .from(workflow)
- .where(eq(workflow.id, existingAgent.workflowId))
- .limit(1)
-
- if (!wf?.isDeployed) {
- return NextResponse.json(
- { error: 'Workflow must be deployed before publishing agent' },
- { status: 400 }
- )
- }
-
- await db
- .update(a2aAgent)
- .set({
- isPublished: true,
- publishedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aAgent.id, agentId))
-
- const redis = getRedisClient()
- if (redis) {
- try {
- await redis.del(`a2a:agent:${agentId}:card`)
- } catch (err) {
- logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
- }
- }
-
- logger.info(`Published A2A agent: ${agentId}`)
- captureServerEvent(
- auth.userId,
- 'a2a_agent_published',
- {
- agent_id: agentId,
- workflow_id: existingAgent.workflowId,
- workspace_id: existingAgent.workspaceId,
- },
- { groups: { workspace: existingAgent.workspaceId } }
- )
- return NextResponse.json({ success: true, isPublished: true })
- }
-
- if (action === 'unpublish') {
- await db
- .update(a2aAgent)
- .set({
- isPublished: false,
- updatedAt: new Date(),
- })
- .where(eq(a2aAgent.id, agentId))
-
- const redis = getRedisClient()
- if (redis) {
- try {
- await redis.del(`a2a:agent:${agentId}:card`)
- } catch (err) {
- logger.warn('Failed to invalidate agent card cache', { agentId, error: err })
- }
- }
-
- logger.info(`Unpublished A2A agent: ${agentId}`)
- captureServerEvent(
- auth.userId,
- 'a2a_agent_unpublished',
- {
- agent_id: agentId,
- workflow_id: existingAgent.workflowId,
- workspace_id: existingAgent.workspaceId,
- },
- { groups: { workspace: existingAgent.workspaceId } }
- )
- return NextResponse.json({ success: true, isPublished: false })
- }
-
- if (action === 'refresh') {
- const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
- if (!workflowData) {
- return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
- }
-
- const [wf] = await db
- .select({ name: workflow.name, description: workflow.description })
- .from(workflow)
- .where(eq(workflow.id, existingAgent.workflowId))
- .limit(1)
-
- const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description)
-
- await db
- .update(a2aAgent)
- .set({
- skills,
- updatedAt: new Date(),
- })
- .where(eq(a2aAgent.id, agentId))
-
- logger.info(`Refreshed skills for A2A agent: ${agentId}`)
- return NextResponse.json({ success: true, skills })
- }
-
- return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
- } catch (error) {
- logger.error('Error with agent action:', error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
diff --git a/apps/sim/app/api/a2a/agents/route.ts b/apps/sim/app/api/a2a/agents/route.ts
deleted file mode 100644
index ee07fb74a78..00000000000
--- a/apps/sim/app/api/a2a/agents/route.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * A2A Agents List Endpoint
- *
- * List and create A2A agents for a workspace.
- */
-
-import { db } from '@sim/db'
-import { a2aAgent, workflow } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { and, eq, isNull, sql } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
-import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
-import { sanitizeAgentName } from '@/lib/a2a/utils'
-import { createA2AAgentContract, listA2AAgentsQuerySchema } from '@/lib/api/contracts/a2a-agents'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { captureServerEvent } from '@/lib/posthog/server'
-import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
-import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
-import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
-
-const logger = createLogger('A2AAgentsAPI')
-
-export const dynamic = 'force-dynamic'
-
-/**
- * GET - List all A2A agents for a workspace
- */
-export const GET = withRouteHandler(async (request: NextRequest) => {
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const queryResult = listA2AAgentsQuerySchema.safeParse({
- workspaceId: request.nextUrl.searchParams.get('workspaceId'),
- })
-
- if (!queryResult.success) {
- return NextResponse.json(
- { error: getValidationErrorMessage(queryResult.error) },
- { status: 400 }
- )
- }
- const { workspaceId } = queryResult.data
-
- const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
- if (!workspaceAccess.exists) {
- return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
- }
- if (!workspaceAccess.hasAccess) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- const agents = await db
- .select({
- id: a2aAgent.id,
- workspaceId: a2aAgent.workspaceId,
- workflowId: a2aAgent.workflowId,
- name: a2aAgent.name,
- description: a2aAgent.description,
- version: a2aAgent.version,
- capabilities: a2aAgent.capabilities,
- skills: a2aAgent.skills,
- authentication: a2aAgent.authentication,
- isPublished: a2aAgent.isPublished,
- publishedAt: a2aAgent.publishedAt,
- createdAt: a2aAgent.createdAt,
- updatedAt: a2aAgent.updatedAt,
- workflowName: workflow.name,
- workflowDescription: workflow.description,
- isDeployed: workflow.isDeployed,
- taskCount: sql`(
- SELECT COUNT(*)::int
- FROM "a2a_task"
- WHERE "a2a_task"."agent_id" = "a2a_agent"."id"
- )`.as('task_count'),
- })
- .from(a2aAgent)
- .leftJoin(workflow, and(eq(a2aAgent.workflowId, workflow.id), isNull(workflow.archivedAt)))
- .where(and(eq(a2aAgent.workspaceId, workspaceId), isNull(a2aAgent.archivedAt)))
- .orderBy(a2aAgent.createdAt)
-
- logger.info(`Listed ${agents.length} A2A agents for workspace ${workspaceId}`)
-
- return NextResponse.json({ success: true, agents })
- } catch (error) {
- logger.error('Error listing agents:', error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
-})
-
-/**
- * POST - Create a new A2A agent from a workflow
- */
-export const POST = withRouteHandler(async (request: NextRequest) => {
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(createA2AAgentContract, request, {})
- if (!parsed.success) return parsed.response
-
- const { workspaceId, workflowId, name, description, capabilities, authentication, skillTags } =
- parsed.data.body
-
- const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
- if (!workspaceAccess.exists) {
- return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
- }
- if (!workspaceAccess.canWrite) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- const [wf] = await db
- .select({
- id: workflow.id,
- name: workflow.name,
- description: workflow.description,
- workspaceId: workflow.workspaceId,
- isDeployed: workflow.isDeployed,
- })
- .from(workflow)
- .where(
- and(
- eq(workflow.id, workflowId),
- eq(workflow.workspaceId, workspaceId),
- isNull(workflow.archivedAt)
- )
- )
- .limit(1)
-
- if (!wf) {
- return NextResponse.json(
- { error: 'Workflow not found or does not belong to workspace' },
- { status: 404 }
- )
- }
-
- const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
- if (!workflowData || !hasValidStartBlockInState(workflowData)) {
- return NextResponse.json(
- { error: 'Workflow must have a Start block to be exposed as an A2A agent' },
- { status: 400 }
- )
- }
-
- const [existing] = await db
- .select({ id: a2aAgent.id })
- .from(a2aAgent)
- .where(
- and(
- eq(a2aAgent.workspaceId, workspaceId),
- eq(a2aAgent.workflowId, workflowId),
- isNull(a2aAgent.archivedAt)
- )
- )
- .limit(1)
-
- if (existing) {
- return NextResponse.json(
- { error: 'An agent already exists for this workflow' },
- { status: 409 }
- )
- }
-
- const skills = generateSkillsFromWorkflow(
- name || wf.name,
- description || wf.description,
- skillTags
- )
-
- const agentId = generateId()
- const agentName = name || sanitizeAgentName(wf.name)
-
- const [agent] = await db
- .insert(a2aAgent)
- .values({
- id: agentId,
- workspaceId,
- workflowId,
- createdBy: auth.userId,
- name: agentName,
- description: description || wf.description,
- version: '1.0.0',
- capabilities: {
- ...A2A_DEFAULT_CAPABILITIES,
- ...capabilities,
- },
- skills,
- authentication: authentication || {
- schemes: ['bearer', 'apiKey'],
- },
- isPublished: false,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning()
-
- logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
-
- captureServerEvent(
- auth.userId,
- 'a2a_agent_created',
- { agent_id: agentId, workflow_id: workflowId, workspace_id: workspaceId },
- {
- groups: { workspace: workspaceId },
- setOnce: { first_a2a_agent_created_at: new Date().toISOString() },
- }
- )
-
- return NextResponse.json({ success: true, agent }, { status: 201 })
- } catch (error) {
- logger.error('Error creating agent:', error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
-})
diff --git a/apps/sim/app/api/a2a/serve/[agentId]/route.ts b/apps/sim/app/api/a2a/serve/[agentId]/route.ts
deleted file mode 100644
index 62c41b0d48a..00000000000
--- a/apps/sim/app/api/a2a/serve/[agentId]/route.ts
+++ /dev/null
@@ -1,1576 +0,0 @@
-import type { Artifact, Message, PushNotificationConfig, TaskState } from '@a2a-js/sdk'
-import { db } from '@sim/db'
-import { a2aAgent, a2aPushNotificationConfig, a2aTask, workflow } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
-import { and, eq, isNull } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { A2A_DEFAULT_TIMEOUT, A2A_MAX_HISTORY_LENGTH } from '@/lib/a2a/constants'
-import { notifyTaskStateChange } from '@/lib/a2a/push-notifications'
-import {
- createAgentMessage,
- extractWorkflowInput,
- isTerminalState,
- parseWorkflowSSEChunk,
-} from '@/lib/a2a/utils'
-import {
- type A2AJsonRpcId,
- type A2AMessageSendParams,
- type A2APushNotificationSetParams,
- type A2ATaskIdParams,
- a2aJsonRpcRequestSchema,
- a2aMessageSendParamsSchema,
- a2aPushNotificationSetParamsSchema,
- a2aServeAgentParamsSchema,
- a2aTaskIdParamsSchema,
-} from '@/lib/api/contracts/a2a-agents'
-import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
-import {
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
- isApiExecutionEntitled,
-} from '@/lib/billing/core/api-access'
-import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
-import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
-import { getClientIp } from '@/lib/core/utils/request'
-import { SSE_HEADERS } from '@/lib/core/utils/sse'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { markExecutionCancelled } from '@/lib/execution/cancellation'
-import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
-import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
-import {
- A2A_ERROR_CODES,
- A2A_METHODS,
- buildExecuteRequest,
- buildTaskResponse,
- createError,
- createResponse,
- extractAgentContent,
- formatTaskResponse,
- generateTaskId,
-} from '@/app/api/a2a/serve/[agentId]/utils'
-import { getBrandConfig } from '@/ee/whitelabeling'
-
-const logger = createLogger('A2AServeAPI')
-
-export const dynamic = 'force-dynamic'
-export const runtime = 'nodejs'
-
-interface RouteParams {
- agentId: string
-}
-
-function getCallerFingerprint(request: NextRequest, userId?: string | null): string {
- if (userId) {
- return `user:${userId}`
- }
-
- const clientIp = getClientIp(request)
- const userAgent = request.headers.get('user-agent')?.trim() || 'unknown'
- return `public:${clientIp}:${userAgent}`
-}
-
-function hasCallerAccessToTask(
- task: typeof a2aTask.$inferSelect,
- callerFingerprint: string
-): boolean {
- const metadata = (task.metadata as Record | null) ?? {}
- const storedFingerprint =
- typeof metadata.callerFingerprint === 'string' ? metadata.callerFingerprint : null
- return !storedFingerprint || storedFingerprint === callerFingerprint
-}
-
-/**
- * GET - Returns the Agent Card (discovery document)
- */
-export const GET = withRouteHandler(
- async (_request: NextRequest, { params }: { params: Promise }) => {
- const { agentId } = a2aServeAgentParamsSchema.parse(await params)
-
- const redis = getRedisClient()
- const cacheKey = `a2a:agent:${agentId}:card`
-
- if (redis) {
- try {
- const cached = await redis.get(cacheKey)
- if (cached) {
- return NextResponse.json(JSON.parse(cached), {
- headers: {
- 'Content-Type': 'application/json',
- 'Cache-Control': 'private, max-age=60',
- 'X-Cache': 'HIT',
- },
- })
- }
- } catch (err) {
- logger.warn('Redis cache read failed', { agentId, error: err })
- }
- }
-
- try {
- const [agent] = await db
- .select({
- id: a2aAgent.id,
- name: a2aAgent.name,
- description: a2aAgent.description,
- version: a2aAgent.version,
- capabilities: a2aAgent.capabilities,
- skills: a2aAgent.skills,
- authentication: a2aAgent.authentication,
- isPublished: a2aAgent.isPublished,
- })
- .from(a2aAgent)
- .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
- .limit(1)
-
- if (!agent) {
- return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
- }
-
- if (!agent.isPublished) {
- return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
- }
-
- const baseUrl = getBaseUrl()
- const brandConfig = getBrandConfig()
-
- const authConfig = agent.authentication as { schemes?: string[] } | undefined
- const schemes = authConfig?.schemes || []
- const isPublic = schemes.includes('none')
-
- const agentCard = {
- protocolVersion: '0.3.0',
- name: agent.name,
- description: agent.description || '',
- url: `${baseUrl}/api/a2a/serve/${agent.id}`,
- version: agent.version,
- preferredTransport: 'JSONRPC',
- documentationUrl: `${baseUrl}/docs/a2a`,
- provider: {
- organization: brandConfig.name,
- url: baseUrl,
- },
- capabilities: agent.capabilities,
- skills: agent.skills || [],
- ...(isPublic
- ? {}
- : {
- securitySchemes: {
- apiKey: {
- type: 'apiKey' as const,
- name: 'X-API-Key',
- in: 'header' as const,
- description: 'API key authentication',
- },
- },
- security: [{ apiKey: [] }],
- }),
- defaultInputModes: ['text/plain', 'application/json'],
- defaultOutputModes: ['text/plain', 'application/json'],
- }
-
- if (redis) {
- try {
- await redis.set(cacheKey, JSON.stringify(agentCard), 'EX', 60)
- } catch (err) {
- logger.warn('Redis cache write failed', { agentId, error: err })
- }
- }
-
- return NextResponse.json(agentCard, {
- headers: {
- 'Content-Type': 'application/json',
- 'Cache-Control': 'private, max-age=60',
- 'X-Cache': 'MISS',
- },
- })
- } catch (error) {
- logger.error('Error getting Agent Card:', error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-/**
- * POST - Handle JSON-RPC requests
- */
-export const POST = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise }) => {
- const { agentId } = a2aServeAgentParamsSchema.parse(await params)
-
- try {
- const [agent] = await db
- .select({
- id: a2aAgent.id,
- name: a2aAgent.name,
- workflowId: a2aAgent.workflowId,
- workspaceId: a2aAgent.workspaceId,
- isPublished: a2aAgent.isPublished,
- capabilities: a2aAgent.capabilities,
- authentication: a2aAgent.authentication,
- })
- .from(a2aAgent)
- .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt)))
- .limit(1)
-
- if (!agent) {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not found'),
- { status: 404 }
- )
- }
-
- if (!agent.isPublished) {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not published'),
- { status: 404 }
- )
- }
-
- const authSchemes = (agent.authentication as { schemes?: string[] })?.schemes || []
- const requiresAuth = !authSchemes.includes('none')
- let authenticatedUserId: string | null = null
- let authenticatedAuthType: AuthResult['authType']
- let authenticatedApiKeyType: AuthResult['apiKeyType']
-
- if (requiresAuth) {
- const auth = await checkHybridAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Unauthorized'),
- { status: 401 }
- )
- }
- authenticatedUserId = auth.userId
- authenticatedAuthType = auth.authType
- authenticatedApiKeyType = auth.apiKeyType
-
- if (auth.apiKeyType === 'workspace' && auth.workspaceId !== agent.workspaceId) {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'),
- { status: 403 }
- )
- }
-
- const workspaceAccess = await checkWorkspaceAccess(agent.workspaceId, authenticatedUserId)
- if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'),
- { status: 403 }
- )
- }
- }
-
- const [wf] = await db
- .select({ isDeployed: workflow.isDeployed })
- .from(workflow)
- .where(and(eq(workflow.id, agent.workflowId), isNull(workflow.archivedAt)))
- .limit(1)
-
- if (!wf?.isDeployed) {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Workflow is not deployed'),
- { status: 400 }
- )
- }
-
- let rawBody: unknown
- try {
- rawBody = await request.json()
- } catch {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.PARSE_ERROR, 'Invalid JSON body'),
- { status: 400 }
- )
- }
-
- const bodyResult = a2aJsonRpcRequestSchema.safeParse(rawBody)
-
- if (!bodyResult.success) {
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request'),
- { status: 400 }
- )
- }
-
- const body = bodyResult.data
- const { id, method, params: rpcParams } = body
- const requestApiKey = request.headers.get('X-API-Key')
- const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null
- const isPersonalApiKeyCaller =
- authenticatedAuthType === AuthType.API_KEY && authenticatedApiKeyType === 'personal'
- const callerFingerprint = getCallerFingerprint(request, authenticatedUserId)
- const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId)
- if (!billedUserId) {
- logger.error('Unable to resolve workspace billed account for A2A execution', {
- agentId: agent.id,
- workspaceId: agent.workspaceId,
- })
- return NextResponse.json(
- createError(
- id,
- A2A_ERROR_CODES.INTERNAL_ERROR,
- 'Unable to resolve billing account for this workspace'
- ),
- { status: 500 }
- )
- }
- if (!(await isApiExecutionEntitled(billedUserId))) {
- return NextResponse.json(
- createError(
- id,
- A2A_ERROR_CODES.AGENT_UNAVAILABLE,
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE
- ),
- { status: 402 }
- )
- }
-
- const executionUserId =
- isPersonalApiKeyCaller && authenticatedUserId ? authenticatedUserId : billedUserId
-
- logger.info(`A2A request: ${method} for agent ${agentId}`)
-
- switch (method) {
- case A2A_METHODS.MESSAGE_SEND: {
- const paramsValidation = a2aMessageSendParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Message is required'),
- { status: 400 }
- )
- }
-
- return handleMessageSend(
- id,
- agent,
- paramsValidation.data,
- apiKey,
- executionUserId,
- callerFingerprint
- )
- }
-
- case A2A_METHODS.MESSAGE_STREAM: {
- const paramsValidation = a2aMessageSendParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Message is required'),
- { status: 400 }
- )
- }
-
- return handleMessageStream(
- request,
- id,
- agent,
- paramsValidation.data,
- apiKey,
- executionUserId,
- callerFingerprint
- )
- }
-
- case A2A_METHODS.TASKS_GET: {
- const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'),
- { status: 400 }
- )
- }
- return handleTaskGet(id, agent.id, paramsValidation.data, callerFingerprint)
- }
-
- case A2A_METHODS.TASKS_CANCEL: {
- const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'),
- { status: 400 }
- )
- }
- return handleTaskCancel(id, agent.id, paramsValidation.data, callerFingerprint)
- }
-
- case A2A_METHODS.TASKS_RESUBSCRIBE: {
- const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'),
- { status: 400 }
- )
- }
-
- return handleTaskResubscribe(
- request,
- id,
- agent.id,
- paramsValidation.data,
- callerFingerprint
- )
- }
-
- case A2A_METHODS.PUSH_NOTIFICATION_SET: {
- const paramsValidation = a2aPushNotificationSetParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification params'),
- { status: 400 }
- )
- }
-
- return handlePushNotificationSet(id, agent.id, paramsValidation.data, callerFingerprint)
- }
-
- case A2A_METHODS.PUSH_NOTIFICATION_GET: {
- const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'),
- { status: 400 }
- )
- }
- return handlePushNotificationGet(id, agent.id, paramsValidation.data, callerFingerprint)
- }
-
- case A2A_METHODS.PUSH_NOTIFICATION_DELETE: {
- const paramsValidation = a2aTaskIdParamsSchema.safeParse(rpcParams)
- if (!paramsValidation.success) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'),
- { status: 400 }
- )
- }
-
- return handlePushNotificationDelete(
- id,
- agent.id,
- paramsValidation.data,
- callerFingerprint
- )
- }
-
- default:
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.METHOD_NOT_FOUND, `Method not found: ${method}`),
- { status: 404 }
- )
- }
- } catch (error) {
- logger.error('Error handling A2A request:', error)
- return NextResponse.json(
- createError(null, A2A_ERROR_CODES.INTERNAL_ERROR, 'Internal error'),
- {
- status: 500,
- }
- )
- }
- }
-)
-
-async function getTaskForAgent(taskId: string, agentId: string, callerFingerprint?: string) {
- const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, taskId)).limit(1)
- if (!task || task.agentId !== agentId) {
- return null
- }
- if (callerFingerprint && !hasCallerAccessToTask(task, callerFingerprint)) {
- return null
- }
- return task
-}
-
-/**
- * Handle message/send - Send a message (v0.3)
- */
-async function handleMessageSend(
- id: A2AJsonRpcId,
- agent: {
- id: string
- name: string
- workflowId: string
- workspaceId: string
- },
- params: A2AMessageSendParams,
- apiKey?: string | null,
- executionUserId?: string,
- callerFingerprint?: string
-): Promise {
- const message = params.message
- const taskId = message.taskId || generateTaskId()
- const contextId = message.contextId || generateId()
-
- // Distributed lock to prevent concurrent task processing
- const lockKey = `a2a:task:${taskId}:lock`
- const lockValue = generateId()
- const acquired = await acquireLock(lockKey, lockValue, 60)
-
- if (!acquired) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INTERNAL_ERROR, 'Task is currently being processed'),
- { status: 409 }
- )
- }
-
- try {
- let existingTask: typeof a2aTask.$inferSelect | null = null
- if (message.taskId) {
- const [found] = await db.select().from(a2aTask).where(eq(a2aTask.id, message.taskId)).limit(1)
- existingTask = found || null
-
- if (!existingTask) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'),
- { status: 404 }
- )
- }
-
- if (existingTask.agentId !== agent.id) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'),
- { status: 404 }
- )
- }
-
- if (callerFingerprint && !hasCallerAccessToTask(existingTask, callerFingerprint)) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'),
- { status: 404 }
- )
- }
-
- if (isTerminalState(existingTask.status as TaskState)) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
- { status: 400 }
- )
- }
- }
-
- const history: Message[] = existingTask?.messages ? (existingTask.messages as Message[]) : []
-
- history.push(message)
-
- if (history.length > A2A_MAX_HISTORY_LENGTH) {
- history.splice(0, history.length - A2A_MAX_HISTORY_LENGTH)
- }
-
- if (existingTask) {
- await db
- .update(a2aTask)
- .set({
- status: 'working',
- messages: history,
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
- } else {
- await db.insert(a2aTask).values({
- id: taskId,
- agentId: agent.id,
- sessionId: contextId || null,
- status: 'working',
- messages: history,
- metadata: callerFingerprint ? { callerFingerprint } : {},
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- }
-
- const {
- url: executeUrl,
- headers,
- useInternalAuth,
- } = await buildExecuteRequest({
- workflowId: agent.workflowId,
- apiKey,
- userId: executionUserId,
- })
-
- logger.info(`Executing workflow ${agent.workflowId} for A2A task ${taskId}`)
-
- try {
- const workflowInput = extractWorkflowInput(message)
- if (!workflowInput) {
- await db
- .update(a2aTask)
- .set({
- status: 'failed',
- completedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- notifyTaskStateChange(taskId, 'failed').catch((err) => {
- logger.error('Failed to trigger push notification for invalid input', {
- taskId,
- error: err,
- })
- })
-
- return NextResponse.json(
- createError(
- id,
- A2A_ERROR_CODES.INVALID_PARAMS,
- 'Message must contain at least one part with content'
- ),
- { status: 400 }
- )
- }
-
- const response = await fetch(executeUrl, {
- method: 'POST',
- headers,
- body: JSON.stringify({
- ...workflowInput,
- triggerType: 'a2a',
- ...(useInternalAuth && { workflowId: agent.workflowId }),
- }),
- signal: AbortSignal.timeout(A2A_DEFAULT_TIMEOUT),
- })
-
- const executeResult = await response.json()
- const executionId = executeResult.executionId || executeResult.metadata?.executionId
- const executionSucceeded = response.ok && executeResult.success !== false
- const finalState: TaskState = executionSucceeded ? 'completed' : 'failed'
-
- const agentContent = extractAgentContent(executeResult)
- const agentMessage = createAgentMessage(agentContent)
- agentMessage.taskId = taskId
- if (contextId) agentMessage.contextId = contextId
- history.push(agentMessage)
-
- const artifacts = executeResult.output?.artifacts || []
-
- await db
- .update(a2aTask)
- .set({
- status: finalState,
- messages: history,
- artifacts,
- executionId,
- completedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- if (isTerminalState(finalState)) {
- notifyTaskStateChange(taskId, finalState).catch((err) => {
- logger.error('Failed to trigger push notification', { taskId, error: err })
- })
- }
-
- const task = buildTaskResponse({
- taskId,
- contextId,
- state: finalState,
- history,
- artifacts,
- })
-
- return NextResponse.json(createResponse(id, task))
- } catch (error) {
- const isTimeout = error instanceof Error && error.name === 'TimeoutError'
- logger.error(`Error executing workflow for task ${taskId}:`, { error, isTimeout })
-
- const errorMessage = isTimeout
- ? `Workflow execution timed out after ${A2A_DEFAULT_TIMEOUT}ms`
- : error instanceof Error
- ? error.message
- : 'Workflow execution failed'
-
- await db
- .update(a2aTask)
- .set({
- status: 'failed',
- updatedAt: new Date(),
- completedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- notifyTaskStateChange(taskId, 'failed').catch((err) => {
- logger.error('Failed to trigger push notification for failure', { taskId, error: err })
- })
-
- return NextResponse.json(createError(id, A2A_ERROR_CODES.INTERNAL_ERROR, errorMessage), {
- status: 500,
- })
- }
- } finally {
- await releaseLock(lockKey, lockValue)
- }
-}
-
-/**
- * Handle message/stream - Stream a message response (v0.3)
- */
-async function handleMessageStream(
- _request: NextRequest,
- id: A2AJsonRpcId,
- agent: {
- id: string
- name: string
- workflowId: string
- workspaceId: string
- },
- params: A2AMessageSendParams,
- apiKey?: string | null,
- executionUserId?: string,
- callerFingerprint?: string
-): Promise {
- const message = params.message
- const contextId = message.contextId || generateId()
- const taskId = message.taskId || generateTaskId()
-
- // Distributed lock to prevent concurrent task processing
- const lockKey = `a2a:task:${taskId}:lock`
- const lockValue = generateId()
- const acquired = await acquireLock(lockKey, lockValue, 300)
-
- if (!acquired) {
- const encoder = new TextEncoder()
- const errorStream = new ReadableStream({
- start(controller) {
- controller.enqueue(
- encoder.encode(
- `event: error\ndata: ${JSON.stringify({ code: A2A_ERROR_CODES.INTERNAL_ERROR, message: 'Task is currently being processed' })}\n\n`
- )
- )
- controller.close()
- },
- })
- return new NextResponse(errorStream, { headers: SSE_HEADERS })
- }
-
- let history: Message[] = []
- let existingTask: typeof a2aTask.$inferSelect | null = null
-
- if (message.taskId) {
- const [found] = await db.select().from(a2aTask).where(eq(a2aTask.id, message.taskId)).limit(1)
- existingTask = found || null
-
- if (!existingTask) {
- await releaseLock(lockKey, lockValue)
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- if (existingTask.agentId !== agent.id) {
- await releaseLock(lockKey, lockValue)
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- if (callerFingerprint && !hasCallerAccessToTask(existingTask, callerFingerprint)) {
- await releaseLock(lockKey, lockValue)
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- if (isTerminalState(existingTask.status as TaskState)) {
- await releaseLock(lockKey, lockValue)
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
- { status: 400 }
- )
- }
-
- history = existingTask.messages as Message[]
- }
-
- history.push(message)
-
- if (history.length > A2A_MAX_HISTORY_LENGTH) {
- history.splice(0, history.length - A2A_MAX_HISTORY_LENGTH)
- }
-
- if (existingTask) {
- await db
- .update(a2aTask)
- .set({
- status: 'working',
- messages: history,
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
- } else {
- await db.insert(a2aTask).values({
- id: taskId,
- agentId: agent.id,
- sessionId: contextId || null,
- status: 'working',
- messages: history,
- metadata: callerFingerprint ? { callerFingerprint } : {},
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- }
-
- const encoder = new TextEncoder()
-
- const stream = new ReadableStream({
- async start(controller) {
- const sendEvent = (event: string, data: unknown) => {
- try {
- const jsonRpcResponse = {
- jsonrpc: '2.0' as const,
- id,
- result: data,
- }
- controller.enqueue(
- encoder.encode(`event: ${event}\ndata: ${JSON.stringify(jsonRpcResponse)}\n\n`)
- )
- } catch (error) {
- logger.error('Error sending SSE event:', error)
- }
- }
-
- sendEvent('status', {
- kind: 'status',
- taskId,
- contextId,
- status: { state: 'working', timestamp: new Date().toISOString() },
- })
-
- try {
- const {
- url: executeUrl,
- headers,
- useInternalAuth,
- } = await buildExecuteRequest({
- workflowId: agent.workflowId,
- apiKey,
- userId: executionUserId,
- stream: true,
- })
-
- const workflowInput = extractWorkflowInput(message)
- if (!workflowInput) {
- await db
- .update(a2aTask)
- .set({
- status: 'failed',
- completedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- notifyTaskStateChange(taskId, 'failed').catch((err) => {
- logger.error('Failed to trigger push notification for invalid streamed input', {
- taskId,
- error: err,
- })
- })
-
- sendEvent('error', {
- code: A2A_ERROR_CODES.INVALID_PARAMS,
- message: 'Message must contain at least one part with content',
- })
- await releaseLock(lockKey, lockValue)
- controller.close()
- return
- }
-
- const response = await fetch(executeUrl, {
- method: 'POST',
- headers,
- body: JSON.stringify({
- ...workflowInput,
- triggerType: 'a2a',
- stream: true,
- ...(useInternalAuth && { workflowId: agent.workflowId }),
- }),
- signal: AbortSignal.timeout(A2A_DEFAULT_TIMEOUT),
- })
-
- if (!response.ok) {
- let errorMessage = 'Workflow execution failed'
- try {
- const errorResult = await response.json()
- errorMessage = errorResult.error || errorMessage
- } catch {
- // Response may not be JSON
- }
- throw new Error(errorMessage)
- }
-
- const contentType = response.headers.get('content-type') || ''
- const streamingExecutionId = response.headers.get('X-Execution-Id') || undefined
- const isStreamingResponse =
- contentType.includes('text/event-stream') || contentType.includes('text/plain')
-
- if (response.body && isStreamingResponse) {
- const reader = response.body.getReader()
- const decoder = new TextDecoder()
- const contentChunks: string[] = []
- let finalContent: string | undefined
- let finalArtifacts: Artifact[] = []
- let sseBuffer = ''
-
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
-
- sseBuffer += decoder.decode(value, { stream: true })
- const frames = sseBuffer.split('\n\n')
- sseBuffer = frames.pop() ?? ''
-
- for (const frame of frames) {
- const parsed = parseWorkflowSSEChunk(frame)
-
- if (parsed.content) {
- contentChunks.push(parsed.content)
- sendEvent('message', {
- kind: 'message',
- taskId,
- contextId,
- role: 'agent',
- parts: [{ kind: 'text', text: parsed.content }],
- final: false,
- })
- }
-
- if (parsed.finalContent) {
- finalContent = parsed.finalContent
- }
- if (parsed.finalArtifacts) {
- finalArtifacts = parsed.finalArtifacts
- }
- if (parsed.terminalState === 'canceled') {
- const agentMessage = createAgentMessage(finalContent || 'Task canceled')
- agentMessage.taskId = taskId
- if (contextId) agentMessage.contextId = contextId
- history.push(agentMessage)
-
- await db
- .update(a2aTask)
- .set({
- status: 'canceled',
- messages: history,
- executionId: streamingExecutionId,
- artifacts: finalArtifacts,
- completedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- notifyTaskStateChange(taskId, 'canceled').catch((err) => {
- logger.error('Failed to trigger push notification', { taskId, error: err })
- })
-
- sendEvent('task', {
- kind: 'task',
- id: taskId,
- contextId,
- status: { state: 'canceled', timestamp: new Date().toISOString() },
- history,
- artifacts: finalArtifacts,
- })
- return
- }
-
- if (parsed.finalSuccess === false) {
- throw new Error('Workflow execution failed')
- }
- }
- }
-
- if (sseBuffer.trim().length > 0) {
- const parsed = parseWorkflowSSEChunk(sseBuffer)
- if (parsed.content) {
- contentChunks.push(parsed.content)
- sendEvent('message', {
- kind: 'message',
- taskId,
- contextId,
- role: 'agent',
- parts: [{ kind: 'text', text: parsed.content }],
- final: false,
- })
- }
- if (parsed.finalContent) {
- finalContent = parsed.finalContent
- }
- if (parsed.finalArtifacts) {
- finalArtifacts = parsed.finalArtifacts
- }
- if (parsed.finalSuccess === false) {
- throw new Error('Workflow execution failed')
- }
- }
-
- const accumulatedContent = contentChunks.join('')
- const messageContent =
- (finalContent !== undefined && finalContent.length > 0
- ? finalContent
- : accumulatedContent) || 'Task completed'
- const agentMessage = createAgentMessage(messageContent)
- agentMessage.taskId = taskId
- if (contextId) agentMessage.contextId = contextId
- history.push(agentMessage)
-
- await db
- .update(a2aTask)
- .set({
- status: 'completed',
- messages: history,
- executionId: streamingExecutionId,
- artifacts: finalArtifacts,
- completedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- notifyTaskStateChange(taskId, 'completed').catch((err) => {
- logger.error('Failed to trigger push notification', { taskId, error: err })
- })
-
- sendEvent('task', {
- kind: 'task',
- id: taskId,
- contextId,
- status: { state: 'completed', timestamp: new Date().toISOString() },
- history,
- artifacts: finalArtifacts,
- })
- } else {
- const result = await response.json()
- const executionSucceeded = result.success !== false
-
- const content = extractAgentContent(result)
-
- sendEvent('message', {
- kind: 'message',
- taskId,
- contextId,
- role: 'agent',
- parts: [{ kind: 'text', text: content }],
- final: true,
- })
-
- const agentMessage = createAgentMessage(content)
- agentMessage.taskId = taskId
- if (contextId) agentMessage.contextId = contextId
- history.push(agentMessage)
-
- const artifacts = (result.output?.artifacts as Artifact[]) || []
-
- await db
- .update(a2aTask)
- .set({
- status: executionSucceeded ? 'completed' : 'failed',
- messages: history,
- artifacts,
- executionId: result.executionId || result.metadata?.executionId,
- completedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- notifyTaskStateChange(taskId, executionSucceeded ? 'completed' : 'failed').catch(
- (err) => {
- logger.error('Failed to trigger push notification', { taskId, error: err })
- }
- )
-
- sendEvent('task', {
- kind: 'task',
- id: taskId,
- contextId,
- status: {
- state: executionSucceeded ? 'completed' : 'failed',
- timestamp: new Date().toISOString(),
- },
- history,
- artifacts,
- })
- }
- } catch (error) {
- const isTimeout = error instanceof Error && error.name === 'TimeoutError'
- logger.error(`Streaming error for task ${taskId}:`, { error, isTimeout })
-
- const errorMessage = isTimeout
- ? `Workflow execution timed out after ${A2A_DEFAULT_TIMEOUT}ms`
- : error instanceof Error
- ? error.message
- : 'Streaming failed'
-
- await db
- .update(a2aTask)
- .set({
- status: 'failed',
- completedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(a2aTask.id, taskId))
-
- notifyTaskStateChange(taskId, 'failed').catch((err) => {
- logger.error('Failed to trigger push notification for failure', { taskId, error: err })
- })
-
- sendEvent('error', {
- code: A2A_ERROR_CODES.INTERNAL_ERROR,
- message: errorMessage,
- })
- } finally {
- await releaseLock(lockKey, lockValue)
- controller.close()
- }
- },
- cancel() {},
- })
-
- return new NextResponse(stream, {
- headers: {
- ...SSE_HEADERS,
- 'X-Task-Id': taskId,
- },
- })
-}
-
-/**
- * Handle tasks/get - Query task status
- */
-async function handleTaskGet(
- id: A2AJsonRpcId,
- agentId: string,
- params: A2ATaskIdParams,
- callerFingerprint?: string
-): Promise {
- const historyLength =
- params.historyLength !== undefined && params.historyLength >= 0
- ? params.historyLength
- : undefined
-
- const task = await getTaskForAgent(params.id, agentId, callerFingerprint)
-
- if (!task) {
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- const taskResponse = buildTaskResponse({
- taskId: task.id,
- contextId: task.sessionId || task.id,
- state: task.status as TaskState,
- history: task.messages as Message[],
- artifacts: (task.artifacts as Artifact[]) || [],
- })
-
- const result = formatTaskResponse(taskResponse, historyLength)
-
- return NextResponse.json(createResponse(id, result))
-}
-
-/**
- * Handle tasks/cancel - Cancel a running task
- */
-async function handleTaskCancel(
- id: A2AJsonRpcId,
- agentId: string,
- params: A2ATaskIdParams,
- callerFingerprint?: string
-): Promise {
- const task = await getTaskForAgent(params.id, agentId, callerFingerprint)
-
- if (!task) {
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- if (isTerminalState(task.status as TaskState)) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
- { status: 400 }
- )
- }
-
- if (task.executionId) {
- try {
- await markExecutionCancelled(task.executionId)
- logger.info('Cancelled workflow execution', {
- taskId: task.id,
- executionId: task.executionId,
- })
- } catch (error) {
- logger.warn('Failed to cancel workflow execution', {
- taskId: task.id,
- executionId: task.executionId,
- error,
- })
- }
- }
-
- await db
- .update(a2aTask)
- .set({
- status: 'canceled',
- updatedAt: new Date(),
- completedAt: new Date(),
- })
- .where(eq(a2aTask.id, params.id))
-
- notifyTaskStateChange(params.id, 'canceled').catch((err) => {
- logger.error('Failed to trigger push notification for cancellation', {
- taskId: params.id,
- error: err,
- })
- })
-
- const canceledTask = buildTaskResponse({
- taskId: task.id,
- contextId: task.sessionId || task.id,
- state: 'canceled',
- history: task.messages as Message[],
- artifacts: (task.artifacts as Artifact[]) || [],
- })
-
- return NextResponse.json(createResponse(id, canceledTask))
-}
-
-/**
- * Handle tasks/resubscribe - Reconnect to SSE stream for an ongoing task
- */
-async function handleTaskResubscribe(
- request: NextRequest,
- id: A2AJsonRpcId,
- agentId: string,
- params: A2ATaskIdParams,
- callerFingerprint?: string
-): Promise {
- const task = await getTaskForAgent(params.id, agentId, callerFingerprint)
-
- if (!task) {
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- const encoder = new TextEncoder()
-
- if (isTerminalState(task.status as TaskState)) {
- const completedTask = buildTaskResponse({
- taskId: task.id,
- contextId: task.sessionId || task.id,
- state: task.status as TaskState,
- history: task.messages as Message[],
- artifacts: (task.artifacts as Artifact[]) || [],
- })
- const jsonRpcResponse = { jsonrpc: '2.0' as const, id, result: completedTask }
- const sseData = `event: task\ndata: ${JSON.stringify(jsonRpcResponse)}\n\n`
- const stream = new ReadableStream({
- start(controller) {
- controller.enqueue(encoder.encode(sseData))
- controller.close()
- },
- })
- return new NextResponse(stream, { headers: SSE_HEADERS })
- }
- let isCancelled = false
- let pollTimeoutId: ReturnType | null = null
-
- const abortSignal = request.signal
- abortSignal.addEventListener(
- 'abort',
- () => {
- isCancelled = true
- if (pollTimeoutId) {
- clearTimeout(pollTimeoutId)
- pollTimeoutId = null
- }
- },
- { once: true }
- )
-
- const cleanup = () => {
- isCancelled = true
- if (pollTimeoutId) {
- clearTimeout(pollTimeoutId)
- pollTimeoutId = null
- }
- }
-
- const stream = new ReadableStream({
- async start(controller) {
- const sendEvent = (event: string, data: unknown): boolean => {
- if (isCancelled || abortSignal.aborted) return false
- try {
- const jsonRpcResponse = { jsonrpc: '2.0' as const, id, result: data }
- controller.enqueue(
- encoder.encode(`event: ${event}\ndata: ${JSON.stringify(jsonRpcResponse)}\n\n`)
- )
- return true
- } catch (error) {
- logger.error('Error sending SSE event:', error)
- isCancelled = true
- return false
- }
- }
-
- if (
- !sendEvent('status', {
- kind: 'status',
- taskId: task.id,
- contextId: task.sessionId,
- status: { state: task.status, timestamp: new Date().toISOString() },
- })
- ) {
- cleanup()
- return
- }
-
- const pollInterval = 3000 // 3 seconds
- const maxPolls = 100 // 5 minutes max
-
- let polls = 0
- const poll = async () => {
- if (isCancelled || abortSignal.aborted) {
- cleanup()
- return
- }
-
- polls++
- if (polls > maxPolls) {
- cleanup()
- try {
- controller.close()
- } catch {
- // Already closed
- }
- return
- }
-
- try {
- const [updatedTask] = await db
- .select()
- .from(a2aTask)
- .where(eq(a2aTask.id, params.id))
- .limit(1)
-
- if (isCancelled) {
- cleanup()
- return
- }
-
- if (!updatedTask) {
- sendEvent('error', { code: A2A_ERROR_CODES.TASK_NOT_FOUND, message: 'Task not found' })
- cleanup()
- try {
- controller.close()
- } catch {
- // Already closed
- }
- return
- }
-
- if (updatedTask.status !== task.status) {
- if (
- !sendEvent('status', {
- kind: 'status',
- taskId: updatedTask.id,
- contextId: updatedTask.sessionId,
- status: { state: updatedTask.status, timestamp: new Date().toISOString() },
- final: isTerminalState(updatedTask.status as TaskState),
- })
- ) {
- cleanup()
- return
- }
- }
-
- if (isTerminalState(updatedTask.status as TaskState)) {
- const messages = updatedTask.messages as Message[]
- const lastMessage = messages[messages.length - 1]
- if (lastMessage && lastMessage.role === 'agent') {
- sendEvent('message', {
- ...lastMessage,
- taskId: updatedTask.id,
- contextId: updatedTask.sessionId || updatedTask.id,
- final: true,
- })
- }
-
- cleanup()
- try {
- controller.close()
- } catch {
- // Already closed
- }
- return
- }
-
- pollTimeoutId = setTimeout(poll, pollInterval)
- } catch (error) {
- logger.error('Error during SSE poll:', error)
- sendEvent('error', {
- code: A2A_ERROR_CODES.INTERNAL_ERROR,
- message: getErrorMessage(error, 'Polling failed'),
- })
- cleanup()
- try {
- controller.close()
- } catch {
- // Already closed
- }
- }
- }
-
- poll()
- },
- cancel() {
- cleanup()
- },
- })
-
- return new NextResponse(stream, {
- headers: {
- ...SSE_HEADERS,
- 'X-Task-Id': params.id,
- },
- })
-}
-
-/**
- * Handle tasks/pushNotificationConfig/set - Set webhook for task updates
- */
-async function handlePushNotificationSet(
- id: A2AJsonRpcId,
- agentId: string,
- params: A2APushNotificationSetParams,
- callerFingerprint?: string
-): Promise {
- const urlValidation = await validateUrlWithDNS(
- params.pushNotificationConfig.url,
- 'Push notification URL'
- )
- if (!urlValidation.isValid) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
- { status: 400 }
- )
- }
-
- const task = await getTaskForAgent(params.id, agentId, callerFingerprint)
-
- if (!task) {
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- const [existingConfig] = await db
- .select()
- .from(a2aPushNotificationConfig)
- .where(eq(a2aPushNotificationConfig.taskId, params.id))
- .limit(1)
-
- const config = params.pushNotificationConfig
-
- if (existingConfig) {
- await db
- .update(a2aPushNotificationConfig)
- .set({
- url: config.url,
- token: config.token || null,
- isActive: true,
- updatedAt: new Date(),
- })
- .where(eq(a2aPushNotificationConfig.id, existingConfig.id))
- } else {
- await db.insert(a2aPushNotificationConfig).values({
- id: generateId(),
- taskId: params.id,
- url: config.url,
- token: config.token || null,
- isActive: true,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- }
-
- const result: PushNotificationConfig = {
- url: config.url,
- token: config.token,
- }
-
- return NextResponse.json(createResponse(id, result))
-}
-
-/**
- * Handle tasks/pushNotificationConfig/get - Get webhook config for a task
- */
-async function handlePushNotificationGet(
- id: A2AJsonRpcId,
- agentId: string,
- params: A2ATaskIdParams,
- callerFingerprint?: string
-): Promise {
- const task = await getTaskForAgent(params.id, agentId, callerFingerprint)
-
- if (!task) {
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- const [config] = await db
- .select()
- .from(a2aPushNotificationConfig)
- .where(eq(a2aPushNotificationConfig.taskId, params.id))
- .limit(1)
-
- if (!config) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Push notification config not found'),
- { status: 404 }
- )
- }
-
- const result: PushNotificationConfig = {
- url: config.url,
- token: config.token || undefined,
- }
-
- return NextResponse.json(createResponse(id, result))
-}
-
-/**
- * Handle tasks/pushNotificationConfig/delete - Delete webhook config for a task
- */
-async function handlePushNotificationDelete(
- id: A2AJsonRpcId,
- agentId: string,
- params: A2ATaskIdParams,
- callerFingerprint?: string
-): Promise {
- const task = await getTaskForAgent(params.id, agentId, callerFingerprint)
-
- if (!task) {
- return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
- status: 404,
- })
- }
-
- const [config] = await db
- .select()
- .from(a2aPushNotificationConfig)
- .where(eq(a2aPushNotificationConfig.taskId, params.id))
- .limit(1)
-
- if (!config) {
- return NextResponse.json(
- createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Push notification config not found'),
- { status: 404 }
- )
- }
-
- await db.delete(a2aPushNotificationConfig).where(eq(a2aPushNotificationConfig.id, config.id))
-
- return NextResponse.json(createResponse(id, { success: true }))
-}
diff --git a/apps/sim/app/api/a2a/serve/[agentId]/utils.ts b/apps/sim/app/api/a2a/serve/[agentId]/utils.ts
deleted file mode 100644
index de4be12323a..00000000000
--- a/apps/sim/app/api/a2a/serve/[agentId]/utils.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
-import { generateId } from '@sim/utils/id'
-import { generateInternalToken } from '@/lib/auth/internal'
-import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
-
-/** A2A v0.3 JSON-RPC method names */
-export const A2A_METHODS = {
- MESSAGE_SEND: 'message/send',
- MESSAGE_STREAM: 'message/stream',
- TASKS_GET: 'tasks/get',
- TASKS_CANCEL: 'tasks/cancel',
- TASKS_RESUBSCRIBE: 'tasks/resubscribe',
- PUSH_NOTIFICATION_SET: 'tasks/pushNotificationConfig/set',
- PUSH_NOTIFICATION_GET: 'tasks/pushNotificationConfig/get',
- PUSH_NOTIFICATION_DELETE: 'tasks/pushNotificationConfig/delete',
-} as const
-
-/** A2A v0.3 error codes */
-export const A2A_ERROR_CODES = {
- PARSE_ERROR: -32700,
- INVALID_REQUEST: -32600,
- METHOD_NOT_FOUND: -32601,
- INVALID_PARAMS: -32602,
- INTERNAL_ERROR: -32603,
- TASK_NOT_FOUND: -32001,
- TASK_ALREADY_COMPLETE: -32002,
- AGENT_UNAVAILABLE: -32003,
- AUTHENTICATION_REQUIRED: -32004,
-} as const
-
-interface JSONRPCRequest {
- jsonrpc: '2.0'
- id: string | number
- method: string
- params?: unknown
-}
-
-export interface JSONRPCResponse {
- jsonrpc: '2.0'
- id: string | number | null
- result?: unknown
- error?: {
- code: number
- message: string
- data?: unknown
- }
-}
-
-interface MessageSendParams {
- message: Message
- configuration?: {
- acceptedOutputModes?: string[]
- historyLength?: number
- pushNotificationConfig?: PushNotificationConfig
- }
-}
-
-interface TaskIdParams {
- id: string
- historyLength?: number
-}
-
-interface PushNotificationSetParams {
- id: string
- pushNotificationConfig: PushNotificationConfig
-}
-
-export function createResponse(id: string | number | null, result: unknown): JSONRPCResponse {
- return { jsonrpc: '2.0', id, result }
-}
-
-export function createError(
- id: string | number | null,
- code: number,
- message: string,
- data?: unknown
-): JSONRPCResponse {
- return { jsonrpc: '2.0', id, error: { code, message, data } }
-}
-
-export function isJSONRPCRequest(obj: unknown): obj is JSONRPCRequest {
- if (!obj || typeof obj !== 'object') return false
- const r = obj as Record
- return r.jsonrpc === '2.0' && typeof r.method === 'string' && r.id !== undefined
-}
-
-export function generateTaskId(): string {
- return generateId()
-}
-
-export function createTaskStatus(state: TaskState): { state: TaskState; timestamp: string } {
- return { state, timestamp: new Date().toISOString() }
-}
-
-export function formatTaskResponse(task: Task, historyLength?: number): Task {
- if (historyLength !== undefined && task.history) {
- return {
- ...task,
- history: task.history.slice(-historyLength),
- }
- }
- return task
-}
-
-export interface ExecuteRequestConfig {
- workflowId: string
- apiKey?: string | null
- userId?: string
- stream?: boolean
-}
-
-export interface ExecuteRequestResult {
- url: string
- headers: Record
- useInternalAuth: boolean
-}
-
-export async function buildExecuteRequest(
- config: ExecuteRequestConfig
-): Promise {
- const url = `${getInternalApiBaseUrl()}/api/workflows/${config.workflowId}/execute`
- const headers: Record = { 'Content-Type': 'application/json' }
- let useInternalAuth = false
-
- if (config.apiKey) {
- headers['X-API-Key'] = config.apiKey
- } else {
- const internalToken = await generateInternalToken(config.userId)
- headers.Authorization = `Bearer ${internalToken}`
- useInternalAuth = true
- }
-
- if (config.stream) {
- headers['X-Stream-Response'] = 'true'
- }
-
- return { url, headers, useInternalAuth }
-}
-
-export function extractAgentContent(executeResult: {
- output?: { content?: string; [key: string]: unknown }
- error?: string
-}): string {
- // Prefer explicit content field
- if (executeResult.output?.content) {
- return executeResult.output.content
- }
-
- // If output is an object with meaningful data, stringify it
- if (typeof executeResult.output === 'object' && executeResult.output !== null) {
- const keys = Object.keys(executeResult.output)
- // Skip empty objects or objects with only undefined values
- if (keys.length > 0 && keys.some((k) => executeResult.output![k] !== undefined)) {
- return JSON.stringify(executeResult.output)
- }
- }
-
- // Fallback to error message or default
- return executeResult.error || 'Task completed'
-}
-
-export function buildTaskResponse(params: {
- taskId: string
- contextId: string
- state: TaskState
- history: Message[]
- artifacts?: Artifact[]
-}): Task {
- return {
- kind: 'task',
- id: params.taskId,
- contextId: params.contextId,
- status: createTaskStatus(params.state),
- history: params.history,
- artifacts: params.artifacts || [],
- }
-}
diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts
index 302f81c3a6a..42e11b0cd34 100644
--- a/apps/sim/app/api/files/serve/[...path]/route.ts
+++ b/apps/sim/app/api/files/serve/[...path]/route.ts
@@ -1,18 +1,14 @@
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
-import { sha256Hex } from '@sim/security/hash'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
DocCompileUserError,
- getE2BDocFormat,
- loadCompiledDocByExt,
+ resolveServableDocBytes,
} from '@/lib/copilot/tools/server/files/doc-compile'
-import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -26,47 +22,14 @@ import {
findLocalFile,
getContentType,
} from '@/app/api/files/utils'
-import type { SandboxTaskId } from '@/sandbox-tasks/registry'
const logger = createLogger('FilesServeAPI')
-const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04])
-const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]) // %PDF-
-
-interface CompilableFormat {
- magic: Buffer
- taskId: SandboxTaskId
- contentType: string
-}
-
-const COMPILABLE_FORMATS: Record = {
- '.pptx': {
- magic: ZIP_MAGIC,
- taskId: 'pptx-generate',
- contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- },
- '.docx': {
- magic: ZIP_MAGIC,
- taskId: 'docx-generate',
- contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- },
- '.pdf': {
- magic: PDF_MAGIC,
- taskId: 'pdf-generate',
- contentType: 'application/pdf',
- },
-}
-
-const MAX_COMPILED_DOC_CACHE = 10
-const compiledDocCache = new Map()
-
-function compiledCacheSet(key: string, buffer: Buffer): void {
- if (compiledDocCache.size >= MAX_COMPILED_DOC_CACHE) {
- compiledDocCache.delete(compiledDocCache.keys().next().value as string)
- }
- compiledDocCache.set(key, buffer)
-}
-
+/**
+ * Resolves the bytes + content type to serve for a stored file via the shared
+ * {@link resolveServableDocBytes} (generated docs → compiled artifact). `raw=1`
+ * bypasses resolution and serves the stored source as-is.
+ */
async function compileDocumentIfNeeded(
buffer: Buffer,
filename: string,
@@ -76,71 +39,13 @@ async function compileDocumentIfNeeded(
signal: AbortSignal | undefined
): Promise<{ buffer: Buffer; contentType: string }> {
if (raw) return { buffer, contentType: getContentType(filename) }
-
- const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase()
- const extNoDot = ext.replace(/^\./, '')
- const format = COMPILABLE_FORMATS[ext]
-
- // Already a binary file (uploaded or pre-compiled)? Serve as-is.
- if (format) {
- const magicLen = format.magic.length
- if (buffer.length >= magicLen && buffer.subarray(0, magicLen).equals(format.magic)) {
- return { buffer, contentType: getContentType(filename) }
- }
- }
-
- // .xlsx is a ZIP container with no JS compile path. An uploaded/binary xlsx
- // must short-circuit here (it isn't in COMPILABLE_FORMATS) — otherwise every
- // xlsx open would utf-8-decode the whole binary and do an always-miss S3 GET.
- // Only a Python-source xlsx (UTF-8 text, no ZIP magic) falls through.
- if (
- extNoDot === 'xlsx' &&
- buffer.length >= ZIP_MAGIC.length &&
- buffer.subarray(0, ZIP_MAGIC.length).equals(ZIP_MAGIC)
- ) {
- return { buffer, contentType: getContentType(filename) }
- }
-
- // Generated docs render from a content-addressed compiled binary that is built
- // exactly ONCE per edit_content/create (at write time) and stored in S3. Serve
- // only LOADS it — it must never compile, or it would re-run E2B on every preview
- // fetch, including against the incomplete source mid-generation. A hit returns
- // the (possibly partial) committed doc; a miss in the E2B regime means the doc
- // is still being generated → 409, and the client polls until the artifact lands.
- if (workspaceId && (format || extNoDot === 'xlsx')) {
- const source = buffer.toString('utf-8')
- // Load the prebuilt artifact directly from S3 (content-addressed). No extra
- // in-memory layer here: the store is the source of truth, the client (react
- // query) already caches the bytes, and this branch never recomputes.
- const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot)
- if (stored) {
- return { buffer: stored.buffer, contentType: stored.contentType }
- }
-
- if (isE2BDocEnabled && (await getE2BDocFormat(filename))) {
- // Artifact not built yet (still generating, or the source didn't compile at
- // write time). Signal "not ready" without compiling — handled as 409.
- throw new DocCompileUserError('Document is still being generated')
- }
- }
-
- if (!format) return { buffer, contentType: getContentType(filename) }
-
- // E2B disabled and no stored artifact → compile JS source via isolated-vm.
- const code = buffer.toString('utf-8')
- const cacheKey = sha256Hex(`${ext}${code}${workspaceId ?? ''}`)
- const cached = compiledDocCache.get(cacheKey)
- if (cached) {
- return { buffer: cached, contentType: format.contentType }
- }
-
- const compiled = await runSandboxTask(
- format.taskId,
- { code, workspaceId: workspaceId || '' },
- { ownerKey, signal }
- )
- compiledCacheSet(cacheKey, compiled)
- return { buffer: compiled, contentType: format.contentType }
+ return resolveServableDocBytes({
+ rawBuffer: buffer,
+ fileName: filename,
+ workspaceId,
+ ownerKey,
+ signal,
+ })
}
const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/
diff --git a/apps/sim/app/api/guardrails/mask-batch/route.ts b/apps/sim/app/api/guardrails/mask-batch/route.ts
index 696b69e749c..b04d3d21106 100644
--- a/apps/sim/app/api/guardrails/mask-batch/route.ts
+++ b/apps/sim/app/api/guardrails/mask-batch/route.ts
@@ -27,8 +27,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const { texts, entityTypes, language } = parsed.data.body
try {
+ const startedAt = performance.now()
const masked = await maskPIIBatch(texts, entityTypes, language)
- logger.info('Masked PII batch', { count: texts.length })
+ logger.info('Masked PII batch', {
+ count: texts.length,
+ durationMs: Math.round(performance.now() - startedAt),
+ })
return NextResponse.json({ masked })
} catch (error) {
// An unreachable/misconfigured Presidio sidecar makes maskPIIBatch throw; fail
diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts
index d9f5beb7e30..82cd8f6bfa4 100644
--- a/apps/sim/app/api/knowledge/[id]/documents/route.ts
+++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts
@@ -8,6 +8,7 @@ import {
bulkKnowledgeDocumentsContract,
createKnowledgeDocumentsContract,
listKnowledgeDocumentsQuerySchema,
+ parseDocumentTagFiltersParam,
} from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
@@ -67,6 +68,18 @@ export const GET = withRouteHandler(
const { enabledFilter, search, limit, offset, sortBy, sortOrder, tagFilters } =
queryResult.data
+ let parsedTagFilters: TagFilterCondition[] | undefined
+ try {
+ parsedTagFilters = parseDocumentTagFiltersParam(tagFilters) as
+ | TagFilterCondition[]
+ | undefined
+ } catch {
+ return NextResponse.json(
+ { error: 'tagFilters must be a valid JSON array' },
+ { status: 400 }
+ )
+ }
+
const result = await getDocuments(
knowledgeBaseId,
{
@@ -76,7 +89,7 @@ export const GET = withRouteHandler(
offset,
...(sortBy && { sortBy }),
...(sortOrder && { sortOrder }),
- tagFilters: tagFilters as TagFilterCondition[] | undefined,
+ tagFilters: parsedTagFilters,
},
requestId
)
diff --git a/apps/sim/app/api/tools/a2a/cancel-task/route.ts b/apps/sim/app/api/tools/a2a/cancel-task/route.ts
index 92935a001a0..c65f6381f09 100644
--- a/apps/sim/app/api/tools/a2a/cancel-task/route.ts
+++ b/apps/sim/app/api/tools/a2a/cancel-task/route.ts
@@ -1,7 +1,7 @@
-import type { Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient } from '@/lib/a2a/utils'
+import { createA2AClient, taskOutput } from '@/lib/a2a/client'
import { a2aCancelTaskContract } from '@/lib/api/contracts/tools/a2a'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
@@ -9,82 +9,52 @@ import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-const logger = createLogger('A2ACancelTaskAPI')
-
export const dynamic = 'force-dynamic'
+export const maxDuration = 60
+
+const logger = createLogger('A2ACancelTaskAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(`[${requestId}] Unauthorized A2A cancel task attempt`)
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit(
- 'a2a-cancel-task',
- authResult.userId,
- request
- )
- if (rateLimited) return rateLimited
-
- const parsed = await parseRequest(
- a2aCancelTaskContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success) {
+ return NextResponse.json(
+ { success: false, error: auth.error || 'Authentication required' },
+ { status: 401 }
)
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- logger.info(`[${requestId}] Canceling A2A task`, {
- agentUrl: validatedData.agentUrl,
- taskId: validatedData.taskId,
- })
-
- const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
+ }
- const task = (await client.cancelTask({ id: validatedData.taskId })) as Task
+ const rateLimited = await enforceUserOrIpRateLimit('a2a-cancel-task', auth.userId, request)
+ if (rateLimited) return rateLimited
+
+ const parsed = await parseRequest(
+ a2aCancelTaskContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json(
+ { success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
+ { status: 400 }
+ ),
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const body = parsed.data.body
- logger.info(`[${requestId}] Successfully canceled A2A task`, {
- taskId: validatedData.taskId,
- state: task.status.state,
- })
+ try {
+ const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
+ const task = await client.cancelTask({ tenant: '', id: body.taskId, metadata: undefined })
+ const out = taskOutput(task)
+ logger.info(`[${requestId}] Cancel requested for A2A task ${task.id}`)
return NextResponse.json({
success: true,
- output: {
- cancelled: true,
- state: task.status.state,
- },
+ output: { taskId: out.taskId, state: out.state, canceled: out.state === 'canceled' },
})
} catch (error) {
- logger.error(`[${requestId}] Error canceling A2A task:`, error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to cancel task',
- },
- { status: 500 }
- )
+ logger.error(`[${requestId}] A2A cancel-task failed`, { error: getErrorMessage(error) })
+ return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
}
})
diff --git a/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts b/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts
deleted file mode 100644
index cf93e9e2f36..00000000000
--- a/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient } from '@/lib/a2a/utils'
-import { a2aDeletePushNotificationContract } from '@/lib/api/contracts/tools/a2a'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-export const dynamic = 'force-dynamic'
-
-const logger = createLogger('A2ADeletePushNotificationAPI')
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(
- `[${requestId}] Unauthorized A2A delete push notification attempt: ${authResult.error}`
- )
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit(
- 'a2a-delete-push-notification',
- authResult.userId,
- request
- )
- if (rateLimited) return rateLimited
-
- logger.info(
- `[${requestId}] Authenticated A2A delete push notification request via ${authResult.authType}`,
- {
- userId: authResult.userId,
- }
- )
-
- const parsed = await parseRequest(
- a2aDeletePushNotificationContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
- )
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- logger.info(`[${requestId}] Deleting A2A push notification config`, {
- agentUrl: validatedData.agentUrl,
- taskId: validatedData.taskId,
- pushNotificationConfigId: validatedData.pushNotificationConfigId,
- })
-
- const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
-
- await client.deleteTaskPushNotificationConfig({
- id: validatedData.taskId,
- pushNotificationConfigId: validatedData.pushNotificationConfigId || validatedData.taskId,
- })
-
- logger.info(`[${requestId}] Push notification config deleted successfully`, {
- taskId: validatedData.taskId,
- })
-
- return NextResponse.json({
- success: true,
- output: {
- success: true,
- },
- })
- } catch (error) {
- logger.error(`[${requestId}] Error deleting A2A push notification:`, error)
-
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to delete push notification',
- },
- { status: 500 }
- )
- }
-})
diff --git a/apps/sim/app/api/tools/a2a/get-agent-card/route.ts b/apps/sim/app/api/tools/a2a/get-agent-card/route.ts
index fed318b8330..52a8c055446 100644
--- a/apps/sim/app/api/tools/a2a/get-agent-card/route.ts
+++ b/apps/sim/app/api/tools/a2a/get-agent-card/route.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient } from '@/lib/a2a/utils'
+import { agentCardOutput, createA2AClient } from '@/lib/a2a/client'
import { a2aGetAgentCardContract } from '@/lib/api/contracts/tools/a2a'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
@@ -9,93 +10,47 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
+export const maxDuration = 60
const logger = createLogger('A2AGetAgentCardAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(`[${requestId}] Unauthorized A2A get agent card attempt: ${authResult.error}`)
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit(
- 'a2a-get-agent-card',
- authResult.userId,
- request
- )
- if (rateLimited) return rateLimited
-
- logger.info(
- `[${requestId}] Authenticated A2A get agent card request via ${authResult.authType}`,
- {
- userId: authResult.userId,
- }
- )
-
- const parsed = await parseRequest(
- a2aGetAgentCardContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success) {
+ return NextResponse.json(
+ { success: false, error: auth.error || 'Authentication required' },
+ { status: 401 }
)
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- logger.info(`[${requestId}] Fetching Agent Card`, {
- agentUrl: validatedData.agentUrl,
- })
-
- const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
+ }
- const agentCard = await client.getAgentCard()
+ const rateLimited = await enforceUserOrIpRateLimit('a2a-get-agent-card', auth.userId, request)
+ if (rateLimited) return rateLimited
+
+ const parsed = await parseRequest(
+ a2aGetAgentCardContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json(
+ { success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
+ { status: 400 }
+ ),
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const body = parsed.data.body
- logger.info(`[${requestId}] Agent Card fetched successfully`, {
- agentName: agentCard.name,
- })
+ try {
+ const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
+ const card = await client.getAgentCard()
- return NextResponse.json({
- success: true,
- output: {
- name: agentCard.name,
- description: agentCard.description,
- url: agentCard.url,
- version: agentCard.protocolVersion,
- capabilities: agentCard.capabilities,
- skills: agentCard.skills,
- defaultInputModes: agentCard.defaultInputModes,
- defaultOutputModes: agentCard.defaultOutputModes,
- },
- })
+ logger.info(`[${requestId}] Fetched agent card for ${card.name}`)
+ return NextResponse.json({ success: true, output: agentCardOutput(card, body.agentUrl) })
} catch (error) {
- logger.error(`[${requestId}] Error fetching Agent Card:`, error)
-
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to fetch Agent Card',
- },
- { status: 500 }
- )
+ logger.error(`[${requestId}] A2A get-agent-card failed`, { error: getErrorMessage(error) })
+ return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
}
})
diff --git a/apps/sim/app/api/tools/a2a/get-push-notification/route.ts b/apps/sim/app/api/tools/a2a/get-push-notification/route.ts
deleted file mode 100644
index 6c48da2648c..00000000000
--- a/apps/sim/app/api/tools/a2a/get-push-notification/route.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient } from '@/lib/a2a/utils'
-import { a2aGetPushNotificationContract } from '@/lib/api/contracts/tools/a2a'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-export const dynamic = 'force-dynamic'
-
-const logger = createLogger('A2AGetPushNotificationAPI')
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(
- `[${requestId}] Unauthorized A2A get push notification attempt: ${authResult.error}`
- )
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit(
- 'a2a-get-push-notification',
- authResult.userId,
- request
- )
- if (rateLimited) return rateLimited
-
- logger.info(
- `[${requestId}] Authenticated A2A get push notification request via ${authResult.authType}`,
- {
- userId: authResult.userId,
- }
- )
-
- const parsed = await parseRequest(
- a2aGetPushNotificationContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
- )
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- logger.info(`[${requestId}] Getting push notification config`, {
- agentUrl: validatedData.agentUrl,
- taskId: validatedData.taskId,
- })
-
- const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
-
- const result = await client.getTaskPushNotificationConfig({
- id: validatedData.taskId,
- })
-
- if (!result || !result.pushNotificationConfig) {
- logger.info(`[${requestId}] No push notification config found for task`, {
- taskId: validatedData.taskId,
- })
- return NextResponse.json({
- success: true,
- output: {
- exists: false,
- },
- })
- }
-
- logger.info(`[${requestId}] Push notification config retrieved successfully`, {
- taskId: validatedData.taskId,
- })
-
- return NextResponse.json({
- success: true,
- output: {
- url: result.pushNotificationConfig.url,
- token: result.pushNotificationConfig.token,
- exists: true,
- },
- })
- } catch (error) {
- if (error instanceof Error && error.message.includes('not found')) {
- logger.info(`[${requestId}] Task not found, returning exists: false`)
- return NextResponse.json({
- success: true,
- output: {
- exists: false,
- },
- })
- }
-
- logger.error(`[${requestId}] Error getting A2A push notification:`, error)
-
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to get push notification',
- },
- { status: 500 }
- )
- }
-})
diff --git a/apps/sim/app/api/tools/a2a/get-task/route.ts b/apps/sim/app/api/tools/a2a/get-task/route.ts
index 3e38b82f80c..2e955c8834f 100644
--- a/apps/sim/app/api/tools/a2a/get-task/route.ts
+++ b/apps/sim/app/api/tools/a2a/get-task/route.ts
@@ -1,7 +1,7 @@
-import type { Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient } from '@/lib/a2a/utils'
+import { createA2AClient, taskOutput } from '@/lib/a2a/client'
import { a2aGetTaskContract } from '@/lib/api/contracts/tools/a2a'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
@@ -10,89 +10,51 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
+export const maxDuration = 60
const logger = createLogger('A2AGetTaskAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(`[${requestId}] Unauthorized A2A get task attempt: ${authResult.error}`)
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit('a2a-get-task', authResult.userId, request)
- if (rateLimited) return rateLimited
-
- logger.info(`[${requestId}] Authenticated A2A get task request via ${authResult.authType}`, {
- userId: authResult.userId,
- })
-
- const parsed = await parseRequest(
- a2aGetTaskContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success) {
+ return NextResponse.json(
+ { success: false, error: auth.error || 'Authentication required' },
+ { status: 401 }
)
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- logger.info(`[${requestId}] Getting A2A task`, {
- agentUrl: validatedData.agentUrl,
- taskId: validatedData.taskId,
- historyLength: validatedData.historyLength,
- })
-
- const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
+ }
- const task = (await client.getTask({
- id: validatedData.taskId,
- historyLength: validatedData.historyLength,
- })) as Task
+ const rateLimited = await enforceUserOrIpRateLimit('a2a-get-task', auth.userId, request)
+ if (rateLimited) return rateLimited
+
+ const parsed = await parseRequest(
+ a2aGetTaskContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json(
+ { success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
+ { status: 400 }
+ ),
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const body = parsed.data.body
- logger.info(`[${requestId}] Successfully retrieved A2A task`, {
- taskId: task.id,
- state: task.status.state,
+ try {
+ const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
+ const task = await client.getTask({
+ tenant: '',
+ id: body.taskId,
+ historyLength: body.historyLength,
})
- return NextResponse.json({
- success: true,
- output: {
- taskId: task.id,
- contextId: task.contextId,
- state: task.status.state,
- artifacts: task.artifacts,
- history: task.history,
- },
- })
+ logger.info(`[${requestId}] Retrieved A2A task ${task.id}`)
+ return NextResponse.json({ success: true, output: taskOutput(task) })
} catch (error) {
- logger.error(`[${requestId}] Error getting A2A task:`, error)
-
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to get task',
- },
- { status: 500 }
- )
+ logger.error(`[${requestId}] A2A get-task failed`, { error: getErrorMessage(error) })
+ return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
}
})
diff --git a/apps/sim/app/api/tools/a2a/resubscribe/route.ts b/apps/sim/app/api/tools/a2a/resubscribe/route.ts
deleted file mode 100644
index bd4bdebabc7..00000000000
--- a/apps/sim/app/api/tools/a2a/resubscribe/route.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import type {
- Artifact,
- Message,
- Task,
- TaskArtifactUpdateEvent,
- TaskState,
- TaskStatusUpdateEvent,
-} from '@a2a-js/sdk'
-import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
-import { a2aResubscribeContract } from '@/lib/api/contracts/tools/a2a'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-const logger = createLogger('A2AResubscribeAPI')
-
-export const dynamic = 'force-dynamic'
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(`[${requestId}] Unauthorized A2A resubscribe attempt`)
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit(
- 'a2a-resubscribe',
- authResult.userId,
- request
- )
- if (rateLimited) return rateLimited
-
- const parsed = await parseRequest(
- a2aResubscribeContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
- )
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
-
- const stream = client.resubscribeTask({ id: validatedData.taskId })
-
- let taskId = validatedData.taskId
- let contextId: string | undefined
- let state: TaskState = 'working'
- let content = ''
- let artifacts: Artifact[] = []
- let history: Message[] = []
-
- for await (const event of stream) {
- if (event.kind === 'message') {
- const msg = event as Message
- content = extractTextContent(msg)
- taskId = msg.taskId || taskId
- contextId = msg.contextId || contextId
- state = 'completed'
- } else if (event.kind === 'task') {
- const task = event as Task
- taskId = task.id
- contextId = task.contextId
- state = task.status.state
- artifacts = task.artifacts || []
- history = task.history || []
- const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
- if (lastAgentMessage) {
- content = extractTextContent(lastAgentMessage)
- }
- } else if ('status' in event) {
- const statusEvent = event as TaskStatusUpdateEvent
- state = statusEvent.status.state
- } else if ('artifact' in event) {
- const artifactEvent = event as TaskArtifactUpdateEvent
- artifacts.push(artifactEvent.artifact)
- }
- }
-
- logger.info(`[${requestId}] Successfully resubscribed to A2A task ${taskId}`)
-
- return NextResponse.json({
- success: true,
- output: {
- taskId,
- contextId,
- state,
- isRunning: !isTerminalState(state),
- artifacts,
- history,
- },
- })
- } catch (error) {
- logger.error(`[${requestId}] Error resubscribing to A2A task:`, error)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to resubscribe',
- },
- { status: 500 }
- )
- }
-})
diff --git a/apps/sim/app/api/tools/a2a/send-message/route.ts b/apps/sim/app/api/tools/a2a/send-message/route.ts
index 708863a8715..4c098b98fda 100644
--- a/apps/sim/app/api/tools/a2a/send-message/route.ts
+++ b/apps/sim/app/api/tools/a2a/send-message/route.ts
@@ -1,225 +1,145 @@
-import type { DataPart, FilePart, Message, Part, Task, TextPart } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
+import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
+import {
+ type A2AFileInput,
+ buildUserMessage,
+ createA2AClient,
+ isTaskResult,
+ messageOutput,
+ taskErrored,
+ taskOutput,
+} from '@/lib/a2a/client'
import { a2aSendMessageContract } from '@/lib/api/contracts/tools/a2a'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
-import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
+import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
+/** Blocking sends wait until the agent reaches a terminal/interrupted state. */
+export const maxDuration = 300
+
+/** Per-file cap on attachments resolved from storage. */
+const A2A_MAX_FILE_BYTES = 10 * 1024 * 1024
+
const logger = createLogger('A2ASendMessageAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(`[${requestId}] Unauthorized A2A send message attempt: ${authResult.error}`)
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit(
- 'a2a-send-message',
- authResult.userId,
- request
- )
- if (rateLimited) return rateLimited
-
- logger.info(
- `[${requestId}] Authenticated A2A send message request via ${authResult.authType}`,
- {
- userId: authResult.userId,
- }
- )
-
- const parsed = await parseRequest(
- a2aSendMessageContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success) {
+ return NextResponse.json(
+ { success: false, error: auth.error || 'Authentication required' },
+ { status: 401 }
)
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- logger.info(`[${requestId}] Sending A2A message`, {
- agentUrl: validatedData.agentUrl,
- hasTaskId: !!validatedData.taskId,
- hasContextId: !!validatedData.contextId,
- })
+ }
- let client
- try {
- client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
- logger.info(`[${requestId}] A2A client created successfully`)
- } catch (clientError) {
- logger.error(`[${requestId}] Failed to create A2A client:`, clientError)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to connect to agent',
- },
- { status: 502 }
- )
+ const rateLimited = await enforceUserOrIpRateLimit('a2a-send-message', auth.userId, request)
+ if (rateLimited) return rateLimited
+
+ const parsed = await parseRequest(
+ a2aSendMessageContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json(
+ { success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
+ { status: 400 }
+ ),
}
+ )
+ if (!parsed.success) return parsed.response
+ const body = parsed.data.body
- const parts: Part[] = []
-
- const textPart: TextPart = { kind: 'text', text: validatedData.message }
- parts.push(textPart)
-
- if (validatedData.data) {
+ let data: unknown
+ if (body.data !== undefined) {
+ if (typeof body.data === 'string') {
try {
- const parsedData = JSON.parse(validatedData.data)
- const dataPart: DataPart = { kind: 'data', data: parsedData }
- parts.push(dataPart)
- } catch (parseError) {
- logger.warn(`[${requestId}] Failed to parse data as JSON, skipping DataPart`, {
- error: toError(parseError).message,
- })
+ data = JSON.parse(body.data)
+ } catch {
+ return NextResponse.json(
+ { success: false, error: 'Data must be valid JSON' },
+ { status: 400 }
+ )
}
+ } else {
+ data = body.data
}
+ }
- if (validatedData.files && validatedData.files.length > 0) {
- for (const file of validatedData.files) {
- if (file.type === 'url') {
- const urlValidation = await validateUrlWithDNS(file.data, 'fileUrl')
- if (!urlValidation.isValid) {
- return NextResponse.json(
- { success: false, error: urlValidation.error },
- { status: 400 }
- )
- }
-
- const filePart: FilePart = {
- kind: 'file',
- file: {
- name: file.name,
- mimeType: file.mime,
- uri: file.data,
- },
- }
- parts.push(filePart)
- } else if (file.type === 'file') {
- let bytes = file.data
- let mimeType = file.mime
-
- if (file.data.startsWith('data:')) {
- const match = file.data.match(/^data:([^;]+);base64,(.+)$/)
- if (match) {
- mimeType = mimeType || match[1]
- bytes = match[2]
- } else {
- bytes = file.data
- }
- }
-
- const filePart: FilePart = {
- kind: 'file',
- file: {
- name: file.name,
- mimeType: mimeType || 'application/octet-stream',
- bytes,
- },
- }
- parts.push(filePart)
- }
+ try {
+ let files: A2AFileInput[] | undefined
+ if (body.files?.length) {
+ if (!auth.userId) {
+ return NextResponse.json(
+ { success: false, error: 'Authentication required to attach files' },
+ { status: 401 }
+ )
}
- }
-
- const message: Message = {
- kind: 'message',
- messageId: generateId(),
- role: 'user',
- parts,
- ...(validatedData.taskId && { taskId: validatedData.taskId }),
- ...(validatedData.contextId && { contextId: validatedData.contextId }),
- }
-
- let result
- try {
- result = await client.sendMessage({ message })
- logger.info(`[${requestId}] A2A sendMessage completed`, { resultKind: result?.kind })
- } catch (sendError) {
- logger.error(`[${requestId}] Failed to send A2A message:`, sendError)
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to send message to agent',
- },
- { status: 502 }
+ const userFiles = processFilesToUserFiles(body.files, requestId, logger)
+ for (const userFile of userFiles) {
+ const denied = await assertToolFileAccess(userFile.key, auth.userId, requestId, logger)
+ if (denied) return denied
+ }
+ files = await Promise.all(
+ userFiles.map(async (userFile) => {
+ const { buffer, contentType } = await downloadServableFileFromStorage(
+ userFile,
+ requestId,
+ logger,
+ { maxBytes: A2A_MAX_FILE_BYTES }
+ )
+ return {
+ bytes: buffer,
+ name: userFile.name,
+ mediaType: contentType || userFile.type || 'application/octet-stream',
+ }
+ })
)
}
- if (result.kind === 'message') {
- const responseMessage = result as Message
+ const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
+ const message = buildUserMessage({
+ text: body.message,
+ data,
+ files,
+ taskId: body.taskId,
+ contextId: body.contextId,
+ })
- logger.info(`[${requestId}] A2A message sent successfully (message response)`)
+ const result = await client.sendMessage({
+ tenant: '',
+ message,
+ configuration: undefined,
+ metadata: undefined,
+ })
- return NextResponse.json({
- success: true,
- output: {
- content: extractTextContent(responseMessage),
- taskId: responseMessage.taskId || '',
- contextId: responseMessage.contextId,
- state: 'completed',
- },
- })
+ if (!isTaskResult(result)) {
+ logger.info(`[${requestId}] A2A send returned a direct message`)
+ return NextResponse.json({ success: true, output: messageOutput(result) })
}
- const task = result as Task
- const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
- const content = lastAgentMessage ? extractTextContent(lastAgentMessage) : ''
-
- logger.info(`[${requestId}] A2A message sent successfully (task response)`, {
- taskId: task.id,
- state: task.status.state,
- })
-
+ const output = taskOutput(result)
+ const errored = taskErrored(result)
+ logger.info(`[${requestId}] A2A send produced task ${result.id} (${output.state})`)
return NextResponse.json({
- success: isTerminalState(task.status.state) && task.status.state !== 'failed',
- output: {
- content,
- taskId: task.id,
- contextId: task.contextId,
- state: task.status.state,
- artifacts: task.artifacts,
- history: task.history,
- },
+ success: !errored,
+ ...(errored ? { error: output.content || `Agent task ${output.state}` } : {}),
+ output,
})
} catch (error) {
- logger.error(`[${requestId}] Error sending A2A message:`, error)
-
- return NextResponse.json(
- {
- success: false,
- error: 'Internal server error',
- },
- { status: 500 }
- )
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] A2A send failed`, { error: getErrorMessage(error) })
+ return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
}
})
diff --git a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts
deleted file mode 100644
index 5511da2d2cc..00000000000
--- a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { createLogger } from '@sim/logger'
-import { type NextRequest, NextResponse } from 'next/server'
-import { createA2AClient } from '@/lib/a2a/utils'
-import { a2aSetPushNotificationContract } from '@/lib/api/contracts/tools/a2a'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
-import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-export const dynamic = 'force-dynamic'
-
-const logger = createLogger('A2ASetPushNotificationAPI')
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
-
- if (!authResult.success) {
- logger.warn(`[${requestId}] Unauthorized A2A set push notification attempt`, {
- error: authResult.error || 'Authentication required',
- })
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- },
- { status: 401 }
- )
- }
-
- const rateLimited = await enforceUserOrIpRateLimit(
- 'a2a-set-push-notification',
- authResult.userId,
- request
- )
- if (rateLimited) return rateLimited
-
- const parsed = await parseRequest(
- a2aSetPushNotificationContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- NextResponse.json(
- {
- success: false,
- error: getValidationErrorMessage(error, 'Invalid request data'),
- details: error.issues,
- },
- { status: 400 }
- ),
- }
- )
- if (!parsed.success) return parsed.response
- const validatedData = parsed.data.body
-
- const urlValidation = await validateUrlWithDNS(validatedData.webhookUrl, 'Webhook URL')
- if (!urlValidation.isValid) {
- logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
- return NextResponse.json(
- {
- success: false,
- error: urlValidation.error,
- },
- { status: 400 }
- )
- }
-
- logger.info(`[${requestId}] A2A set push notification request`, {
- agentUrl: validatedData.agentUrl,
- taskId: validatedData.taskId,
- webhookUrl: validatedData.webhookUrl,
- })
-
- const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
-
- const result = await client.setTaskPushNotificationConfig({
- taskId: validatedData.taskId,
- pushNotificationConfig: {
- url: validatedData.webhookUrl,
- token: validatedData.token,
- },
- })
-
- logger.info(`[${requestId}] A2A set push notification successful`, {
- taskId: validatedData.taskId,
- })
-
- return NextResponse.json({
- success: true,
- output: {
- url: result.pushNotificationConfig.url,
- token: result.pushNotificationConfig.token,
- success: true,
- },
- })
- } catch (error) {
- logger.error(`[${requestId}] Error setting A2A push notification:`, error)
-
- return NextResponse.json(
- {
- success: false,
- error: 'Failed to set push notification',
- },
- { status: 500 }
- )
- }
-})
diff --git a/apps/sim/app/api/tools/agiloft/attach/route.test.ts b/apps/sim/app/api/tools/agiloft/attach/route.test.ts
index f1e4c8c4264..3f1ac2d1fae 100644
--- a/apps/sim/app/api/tools/agiloft/attach/route.test.ts
+++ b/apps/sim/app/api/tools/agiloft/attach/route.test.ts
@@ -21,7 +21,7 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({
processFilesToUserFiles: mockProcessFilesToUserFiles,
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
- downloadFileFromStorage: mockDownloadFileFromStorage,
+ downloadServableFileFromStorage: mockDownloadFileFromStorage,
}))
vi.mock('@/app/api/files/authorization', () => ({
assertToolFileAccess: mockAssertToolFileAccess,
@@ -77,7 +77,10 @@ beforeEach(() => {
{ key: 's3://bucket/file.txt', name: 'file.txt', size: 5, type: 'text/plain' },
])
mockAssertToolFileAccess.mockResolvedValue(null)
- mockDownloadFileFromStorage.mockResolvedValue(Buffer.from('hello'))
+ mockDownloadFileFromStorage.mockResolvedValue({
+ buffer: Buffer.from('hello'),
+ contentType: 'application/octet-stream',
+ })
})
describe('POST /api/tools/agiloft/attach', () => {
diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts
index b0fcb351751..64da0e6acbb 100644
--- a/apps/sim/app/api/tools/agiloft/attach/route.ts
+++ b/apps/sim/app/api/tools/agiloft/attach/route.ts
@@ -9,7 +9,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { buildAttachFileUrl } from '@/tools/agiloft/utils'
import {
@@ -74,7 +75,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+
+ let fileBuffer: Buffer
+ try {
+ const servable = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = servable.buffer
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download file from storage:`, error)
+ return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
+ }
+
const resolvedFileName = data.fileName || userFile.name || 'attachment'
let resolvedIP: string
diff --git a/apps/sim/app/api/tools/box/upload/route.ts b/apps/sim/app/api/tools/box/upload/route.ts
index 95ca9979054..57ec5f4223c 100644
--- a/apps/sim/app/api/tools/box/upload/route.ts
+++ b/apps/sim/app/api/tools/box/upload/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -53,7 +54,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ try {
+ const result = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = result.buffer
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to download file') },
+ { status: 500 }
+ )
+ }
fileName = validatedData.fileName || userFile.name
} else if (validatedData.fileContent) {
logger.info(`[${requestId}] Using legacy base64 content input`)
diff --git a/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts b/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts
index 4453ddc1dfa..eae6759b6cb 100644
--- a/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts
+++ b/apps/sim/app/api/tools/brex/upload-receipt/route.test.ts
@@ -21,7 +21,7 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({
processFilesToUserFiles: mockProcessFilesToUserFiles,
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
- downloadFileFromStorage: mockDownloadFileFromStorage,
+ downloadServableFileFromStorage: mockDownloadFileFromStorage,
}))
vi.mock('@/app/api/files/authorization', () => ({
assertToolFileAccess: mockAssertToolFileAccess,
@@ -65,7 +65,10 @@ beforeEach(() => {
{ key: 'uploads/receipt.pdf', name: 'receipt.pdf', size: 5, type: 'application/pdf' },
])
mockAssertToolFileAccess.mockResolvedValue(null)
- mockDownloadFileFromStorage.mockResolvedValue(Buffer.from('receipt-bytes'))
+ mockDownloadFileFromStorage.mockResolvedValue({
+ buffer: Buffer.from('receipt-bytes'),
+ contentType: 'application/pdf',
+ })
})
describe('POST /api/tools/brex/upload-receipt', () => {
@@ -192,7 +195,10 @@ describe('POST /api/tools/brex/upload-receipt', () => {
})
it('rejects files over the 50 MB limit', async () => {
- mockDownloadFileFromStorage.mockResolvedValueOnce(Buffer.alloc(50 * 1024 * 1024 + 1))
+ mockDownloadFileFromStorage.mockResolvedValueOnce({
+ buffer: Buffer.alloc(50 * 1024 * 1024 + 1),
+ contentType: 'application/pdf',
+ })
const response = await POST(createMockRequest('POST', baseBody))
expect(response.status).toBe(400)
diff --git a/apps/sim/app/api/tools/brex/upload-receipt/route.ts b/apps/sim/app/api/tools/brex/upload-receipt/route.ts
index 792e9ab6d52..6fb4ccfecf8 100644
--- a/apps/sim/app/api/tools/brex/upload-receipt/route.ts
+++ b/apps/sim/app/api/tools/brex/upload-receipt/route.ts
@@ -11,7 +11,8 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { BREX_API_BASE, buildBrexHeaders } from '@/tools/brex/utils'
@@ -48,7 +49,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let fileBuffer: Buffer
+ try {
+ const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = resolved.buffer
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download receipt file:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Unknown error') },
+ { status: 500 }
+ )
+ }
if (fileBuffer.length > MAX_RECEIPT_SIZE_BYTES) {
return NextResponse.json(
{ success: false, error: 'Receipt file exceeds the 50 MB limit' },
diff --git a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts
index be4932a9964..00057e4d10c 100644
--- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts
+++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts
@@ -7,7 +7,8 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processSingleFileToUserFile, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
@@ -91,9 +92,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (denied) return denied
let fileBuffer: Buffer
+ let resolvedContentType: string
try {
- fileBuffer = await downloadFileFromStorage(userFile, 'confluence-upload', logger)
+ const servable = await downloadServableFileFromStorage(userFile, 'confluence-upload', logger)
+ fileBuffer = servable.buffer
+ resolvedContentType = servable.contentType
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error('Failed to download file from storage:', error)
return NextResponse.json(
{
@@ -104,7 +110,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const uploadFileName = fileName || userFile.name || 'attachment'
- const mimeType = userFile.type || 'application/octet-stream'
+ const mimeType = resolvedContentType || userFile.type || 'application/octet-stream'
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/content/${pageId}/child/attachment`
diff --git a/apps/sim/app/api/tools/daytona/upload/route.ts b/apps/sim/app/api/tools/daytona/upload/route.ts
index 52fb2ec70ad..5c52509bb75 100644
--- a/apps/sim/app/api/tools/daytona/upload/route.ts
+++ b/apps/sim/app/api/tools/daytona/upload/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { DAYTONA_TOOLBOX_BASE_URL, extractDaytonaError } from '@/tools/daytona/utils'
@@ -60,7 +61,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`)
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ try {
+ const servable = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = servable.buffer
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download file from storage:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to download file') },
+ { status: 500 }
+ )
+ }
fileName = params.fileName || userFile.name
} else if (params.fileContent) {
logger.info(`[${requestId}] Using legacy base64 content input`)
diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts
index 5efc1b44d21..aa644457654 100644
--- a/apps/sim/app/api/tools/discord/send-message/route.ts
+++ b/apps/sim/app/api/tools/discord/send-message/route.ts
@@ -8,7 +8,8 @@ import { validateNumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -143,31 +144,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- userFiles.map(async (file, i) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ userFiles.map(async (file, i) => {
logger.info(`[${requestId}] Downloading file ${i}: ${file.name}`)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
for (let i = 0; i < userFiles.length; i++) {
const userFile = userFiles[i]
- const buffer = buffers[i]
+ const buffer = resolved[i].buffer
+ const mimeType = resolved[i].contentType || userFile.type || 'application/octet-stream'
logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`)
filesOutput.push({
name: userFile.name,
- mimeType: userFile.type || 'application/octet-stream',
+ mimeType,
data: buffer.toString('base64'),
size: buffer.length,
})
- const blob = new Blob([new Uint8Array(buffer)], { type: userFile.type })
+ const blob = new Blob([new Uint8Array(buffer)], { type: mimeType })
formData.append(`files[${i}]`, blob, userFile.name)
}
diff --git a/apps/sim/app/api/tools/docusign/route.ts b/apps/sim/app/api/tools/docusign/route.ts
index 594587fc510..e36b6cee009 100644
--- a/apps/sim/app/api/tools/docusign/route.ts
+++ b/apps/sim/app/api/tools/docusign/route.ts
@@ -17,7 +17,8 @@ import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
const logger = createLogger('DocuSignAPI')
@@ -228,14 +229,21 @@ async function handleSendEnvelope(
{ status: 413 }
)
}
- const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger, {
- maxBytes: MAX_DOCUSIGN_DOCUMENT_BYTES,
- })
+ const { buffer } = await downloadServableFileFromStorage(
+ userFile,
+ 'docusign-send',
+ logger,
+ {
+ maxBytes: MAX_DOCUSIGN_DOCUMENT_BYTES,
+ }
+ )
assertKnownSizeWithinLimit(buffer.length, MAX_DOCUSIGN_DOCUMENT_BYTES, 'DocuSign document')
documentBase64 = buffer.toString('base64')
documentName = userFile.name
}
} catch (fileError) {
+ const notReady = docNotReadyResponse(fileError)
+ if (notReady) return notReady
logger.error('Failed to process file for DocuSign envelope', { fileError })
return NextResponse.json(
{
diff --git a/apps/sim/app/api/tools/dropbox/upload/route.ts b/apps/sim/app/api/tools/dropbox/upload/route.ts
index d9f375c819a..c68967b839b 100644
--- a/apps/sim/app/api/tools/dropbox/upload/route.ts
+++ b/apps/sim/app/api/tools/dropbox/upload/route.ts
@@ -8,7 +8,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { httpHeaderSafeJson } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -56,7 +57,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ try {
+ const result = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = result.buffer
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to download file') },
+ { status: 500 }
+ )
+ }
fileName = userFile.name
} else if (validatedData.fileContent) {
// Legacy: base64 string input (backwards compatibility)
diff --git a/apps/sim/app/api/tools/elevenlabs/audio/route.ts b/apps/sim/app/api/tools/elevenlabs/audio/route.ts
new file mode 100644
index 00000000000..f4f83f399a5
--- /dev/null
+++ b/apps/sim/app/api/tools/elevenlabs/audio/route.ts
@@ -0,0 +1,209 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { elevenLabsAudioToolContract } from '@/lib/api/contracts/tools/media/elevenlabs'
+import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
+import { validateAlphanumericId } from '@/lib/core/security/input-validation'
+import {
+ isPayloadSizeLimitError,
+ readResponseToBufferWithLimit,
+} from '@/lib/core/utils/stream-limits'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { StorageService } from '@/lib/uploads'
+import { getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
+import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { assertToolFileAccess } from '@/app/api/files/authorization'
+
+const logger = createLogger('ElevenLabsAudioAPI')
+const MAX_AUDIO_BYTES = 25 * 1024 * 1024
+const BASE_URL = 'https://api.elevenlabs.io/v1'
+
+type AudioOperation = 'sound_effects' | 'speech_to_speech' | 'audio_isolation'
+
+interface SourceAudio {
+ buffer: Buffer
+ fileName: string
+ mimeType: string
+}
+
+/** Builds the upstream ElevenLabs request for an audio-producing operation. */
+function buildElevenLabsRequest(
+ operation: AudioOperation,
+ body: {
+ apiKey: string
+ voiceId?: string
+ text?: string
+ modelId?: string
+ durationSeconds?: number
+ promptInfluence?: number
+ loop?: boolean
+ removeBackgroundNoise?: boolean
+ },
+ source: SourceAudio | null
+): { url: string; init: RequestInit } {
+ const headers: Record = { 'xi-api-key': body.apiKey, Accept: 'audio/mpeg' }
+ const signal = AbortSignal.timeout(DEFAULT_EXECUTION_TIMEOUT_MS)
+
+ if (operation === 'sound_effects') {
+ const payload: Record = { text: body.text }
+ if (body.modelId) payload.model_id = body.modelId
+ if (body.durationSeconds !== undefined) payload.duration_seconds = body.durationSeconds
+ if (body.promptInfluence !== undefined) payload.prompt_influence = body.promptInfluence
+ if (body.loop !== undefined) payload.loop = body.loop
+ return {
+ url: `${BASE_URL}/sound-generation`,
+ init: {
+ method: 'POST',
+ headers: { ...headers, 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ signal,
+ },
+ }
+ }
+
+ const formData = new FormData()
+ const file = source as SourceAudio
+ formData.append(
+ 'audio',
+ new Blob([new Uint8Array(file.buffer)], { type: file.mimeType }),
+ file.fileName
+ )
+
+ if (operation === 'speech_to_speech') {
+ if (body.modelId) formData.append('model_id', body.modelId)
+ if (body.removeBackgroundNoise !== undefined) {
+ formData.append('remove_background_noise', String(body.removeBackgroundNoise))
+ }
+ return {
+ url: `${BASE_URL}/speech-to-speech/${body.voiceId}`,
+ init: { method: 'POST', headers, body: formData, signal },
+ }
+ }
+
+ return {
+ url: `${BASE_URL}/audio-isolation`,
+ init: { method: 'POST', headers, body: formData, signal },
+ }
+}
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId()
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+ if (!authResult.success || !authResult.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const userId = authResult.userId
+
+ const parsed = await parseRequest(
+ elevenLabsAudioToolContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json(
+ { error: getValidationErrorMessage(error, 'Missing required parameters') },
+ { status: 400 }
+ ),
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const body = parsed.data.body
+ const operation = body.operation as AudioOperation
+
+ if (operation === 'sound_effects' && !body.text) {
+ return NextResponse.json({ error: 'text is required' }, { status: 400 })
+ }
+
+ let source: SourceAudio | null = null
+ if (operation === 'speech_to_speech' || operation === 'audio_isolation') {
+ if (!body.audioFile) {
+ return NextResponse.json({ error: 'audioFile is required' }, { status: 400 })
+ }
+ const file = body.audioFile
+ const denied = await assertToolFileAccess(file.key, userId, requestId, logger)
+ if (denied) return denied
+ const buffer = await downloadFileFromStorage(file, requestId, logger)
+ const ext = file.name.split('.').pop()?.toLowerCase() || ''
+ source = {
+ buffer,
+ fileName: file.name,
+ mimeType: file.type || getMimeTypeFromExtension(ext),
+ }
+ }
+
+ if (operation === 'speech_to_speech') {
+ if (!body.voiceId) {
+ return NextResponse.json({ error: 'voiceId is required' }, { status: 400 })
+ }
+ const voiceIdValidation = validateAlphanumericId(body.voiceId, 'voiceId', 255)
+ if (!voiceIdValidation.isValid) {
+ return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 })
+ }
+ }
+
+ const { url, init } = buildElevenLabsRequest(operation, body, source)
+ const response = await fetch(url, init)
+
+ if (!response.ok) {
+ const errorBody = await response.text().catch(() => '')
+ logger.error(`[${requestId}] ElevenLabs ${operation} failed: ${response.status}`, errorBody)
+ return NextResponse.json(
+ { error: `ElevenLabs request failed: ${response.status} ${response.statusText}` },
+ { status: response.status }
+ )
+ }
+
+ const outputBuffer = await readResponseToBufferWithLimit(response, {
+ maxBytes: MAX_AUDIO_BYTES,
+ label: `ElevenLabs ${operation} response`,
+ signal: request.signal,
+ })
+
+ if (outputBuffer.length === 0) {
+ return NextResponse.json({ error: 'Empty audio received' }, { status: 422 })
+ }
+
+ const fileName = `elevenlabs-${operation}-${Date.now()}.mp3`
+ const executionContext =
+ body.workspaceId && body.workflowId && body.executionId
+ ? {
+ workspaceId: body.workspaceId,
+ workflowId: body.workflowId,
+ executionId: body.executionId,
+ }
+ : null
+
+ if (executionContext) {
+ const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution')
+ const userFile = await uploadExecutionFile(
+ executionContext,
+ outputBuffer,
+ fileName,
+ 'audio/mpeg',
+ userId
+ )
+ return NextResponse.json({ audioFile: userFile, audioUrl: userFile.url })
+ }
+
+ const fileInfo = await StorageService.uploadFile({
+ file: outputBuffer,
+ fileName,
+ contentType: 'audio/mpeg',
+ context: 'copilot',
+ })
+ return NextResponse.json({ audioUrl: `${getBaseUrl()}${fileInfo.path}`, size: fileInfo.size })
+ } catch (error) {
+ logger.error(`[${requestId}] ElevenLabs audio proxy error:`, error)
+ return NextResponse.json(
+ { error: `Internal Server Error: ${getErrorMessage(error, 'Unknown error')}` },
+ { status: isPayloadSizeLimitError(error) ? 413 : 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts
index 367a5db8cfc..4a2b9e260b1 100644
--- a/apps/sim/app/api/tools/file/manage/route.ts
+++ b/apps/sim/app/api/tools/file/manage/route.ts
@@ -31,7 +31,11 @@ import {
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import {
+ downloadFileFromStorage,
+ downloadServableFileFromStorage,
+} from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
assertActiveWorkspaceAccess,
@@ -306,7 +310,7 @@ const extractUserFileTextContent = async (
userFile: UserFile,
requestId: string
): Promise => {
- const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
+ const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger, {
maxBytes: MAX_GET_CONTENT_FILE_BYTES,
})
@@ -1038,6 +1042,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 403 }
)
}
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
if (error instanceof ShareValidationError) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 })
}
diff --git a/apps/sim/app/api/tools/firecrawl/parse/route.ts b/apps/sim/app/api/tools/firecrawl/parse/route.ts
index 409f74a6f16..610df2f45a2 100644
--- a/apps/sim/app/api/tools/firecrawl/parse/route.ts
+++ b/apps/sim/app/api/tools/firecrawl/parse/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -47,11 +48,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ const { buffer, contentType } = await downloadServableFileFromStorage(
+ userFile,
+ requestId,
+ logger
+ )
const formData = new FormData()
const blob = new Blob([new Uint8Array(buffer)], {
- type: userFile.type || 'application/octet-stream',
+ type: contentType || userFile.type || 'application/octet-stream',
})
formData.append('file', blob, userFile.name)
@@ -88,6 +93,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
output: firecrawlData.data ?? firecrawlData,
})
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Error in Firecrawl parse:`, error)
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
}
diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts
index ca4ab7b2599..e376be1f01e 100644
--- a/apps/sim/app/api/tools/gmail/draft/route.ts
+++ b/apps/sim/app/api/tools/gmail/draft/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
base64UrlEncode,
@@ -94,26 +95,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- attachments.map(async (file) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ attachments.map(async (file) => {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0)
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`,
+ },
+ { status: 400 }
+ )
+ }
const attachmentBuffers = attachments.map((file, i) => ({
filename: file.name,
- mimeType: file.type || 'application/octet-stream',
- content: buffers[i],
+ mimeType: resolved[i].contentType || file.type || 'application/octet-stream',
+ content: resolved[i].buffer,
}))
const mimeMessage = buildMimeMessage({
diff --git a/apps/sim/app/api/tools/gmail/edit-draft/route.ts b/apps/sim/app/api/tools/gmail/edit-draft/route.ts
index cac63c2ab92..f986a610f11 100644
--- a/apps/sim/app/api/tools/gmail/edit-draft/route.ts
+++ b/apps/sim/app/api/tools/gmail/edit-draft/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
base64UrlEncode,
@@ -90,26 +91,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- attachments.map(async (file) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ attachments.map(async (file) => {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0)
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`,
+ },
+ { status: 400 }
+ )
+ }
const attachmentBuffers = attachments.map((file, i) => ({
filename: file.name,
- mimeType: file.type || 'application/octet-stream',
- content: buffers[i],
+ mimeType: resolved[i].contentType || file.type || 'application/octet-stream',
+ content: resolved[i].buffer,
}))
const mimeMessage = buildMimeMessage({
diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts
index 81818bfc98e..59df7377b34 100644
--- a/apps/sim/app/api/tools/gmail/send/route.ts
+++ b/apps/sim/app/api/tools/gmail/send/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
base64UrlEncode,
@@ -94,26 +95,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- attachments.map(async (file) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ attachments.map(async (file) => {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0)
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`,
+ },
+ { status: 400 }
+ )
+ }
const attachmentBuffers = attachments.map((file, i) => ({
filename: file.name,
- mimeType: file.type || 'application/octet-stream',
- content: buffers[i],
+ mimeType: resolved[i].contentType || file.type || 'application/octet-stream',
+ content: resolved[i].buffer,
}))
const mimeMessage = buildMimeMessage({
diff --git a/apps/sim/app/api/tools/google_drive/export/route.ts b/apps/sim/app/api/tools/google_drive/export/route.ts
new file mode 100644
index 00000000000..40b48057def
--- /dev/null
+++ b/apps/sim/app/api/tools/google_drive/export/route.ts
@@ -0,0 +1,209 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { googleDriveExportContract } from '@/lib/api/contracts/tools/google'
+import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import {
+ secureFetchWithPinnedIP,
+ validateUrlWithDNS,
+} from '@/lib/core/security/input-validation.server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import type { GoogleDriveFile } from '@/tools/google_drive/types'
+import {
+ ALL_FILE_FIELDS,
+ GOOGLE_WORKSPACE_MIME_TYPES,
+ MAX_EXPORT_BYTES,
+ VALID_EXPORT_FORMATS,
+} from '@/tools/google_drive/utils'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('GoogleDriveExportAPI')
+
+/** Google API error response structure */
+interface GoogleApiErrorResponse {
+ error?: {
+ message?: string
+ code?: number
+ status?: string
+ }
+}
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized Google Drive export attempt: ${authResult.error}`)
+ return NextResponse.json(
+ { success: false, error: authResult.error || 'Authentication required' },
+ { status: 401 }
+ )
+ }
+
+ const parsed = await parseRequest(
+ googleDriveExportContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json(
+ { success: false, error: getValidationErrorMessage(error, 'Invalid request') },
+ { status: 400 }
+ ),
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { accessToken, fileId, mimeType: exportMimeType, fileName } = parsed.data.body
+ const authHeader = `Bearer ${accessToken}`
+
+ logger.info(`[${requestId}] Getting file metadata from Google Drive`, { fileId })
+
+ const metadataUrl = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=${ALL_FILE_FIELDS}&supportsAllDrives=true`
+ const metadataUrlValidation = await validateUrlWithDNS(metadataUrl, 'metadataUrl')
+ if (!metadataUrlValidation.isValid) {
+ return NextResponse.json(
+ { success: false, error: metadataUrlValidation.error },
+ { status: 400 }
+ )
+ }
+
+ const metadataResponse = await secureFetchWithPinnedIP(
+ metadataUrl,
+ metadataUrlValidation.resolvedIP!,
+ { headers: { Authorization: authHeader } }
+ )
+
+ if (!metadataResponse.ok) {
+ const errorDetails = (await metadataResponse
+ .json()
+ .catch(() => ({}))) as GoogleApiErrorResponse
+ logger.error(`[${requestId}] Failed to get file metadata`, {
+ status: metadataResponse.status,
+ error: errorDetails,
+ })
+ return NextResponse.json(
+ { success: false, error: errorDetails.error?.message || 'Failed to get file metadata' },
+ { status: 400 }
+ )
+ }
+
+ const metadata = (await metadataResponse.json()) as GoogleDriveFile
+ const fileMimeType = metadata.mimeType
+
+ if (!GOOGLE_WORKSPACE_MIME_TYPES.includes(fileMimeType)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Export only supports Google Workspace files (Docs, Sheets, Slides, Drawings). This file is "${fileMimeType}" — use the Download operation instead.`,
+ },
+ { status: 400 }
+ )
+ }
+
+ const validFormats = VALID_EXPORT_FORMATS[fileMimeType]
+ if (validFormats && !validFormats.includes(exportMimeType)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Export format "${exportMimeType}" is not supported for this file type. Supported formats: ${validFormats.join(', ')}`,
+ },
+ { status: 400 }
+ )
+ }
+
+ logger.info(`[${requestId}] Exporting Google Workspace file`, {
+ fileId,
+ mimeType: fileMimeType,
+ exportFormat: exportMimeType,
+ })
+
+ const exportUrl = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(exportMimeType)}`
+ const exportUrlValidation = await validateUrlWithDNS(exportUrl, 'exportUrl')
+ if (!exportUrlValidation.isValid) {
+ return NextResponse.json(
+ { success: false, error: exportUrlValidation.error },
+ { status: 400 }
+ )
+ }
+
+ const exportResponse = await secureFetchWithPinnedIP(
+ exportUrl,
+ exportUrlValidation.resolvedIP!,
+ {
+ headers: { Authorization: authHeader },
+ }
+ )
+
+ if (!exportResponse.ok) {
+ const exportError = (await exportResponse.json().catch(() => ({}))) as GoogleApiErrorResponse
+ logger.error(`[${requestId}] Failed to export file`, {
+ status: exportResponse.status,
+ error: exportError,
+ })
+ return NextResponse.json(
+ {
+ success: false,
+ error: exportError.error?.message || 'Failed to export Google Workspace file',
+ },
+ { status: 400 }
+ )
+ }
+
+ const declaredSize = Number(exportResponse.headers.get('content-length'))
+ if (Number.isFinite(declaredSize) && declaredSize > MAX_EXPORT_BYTES) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Exported content (${declaredSize} bytes) exceeds the ${MAX_EXPORT_BYTES}-byte export limit.`,
+ },
+ { status: 400 }
+ )
+ }
+
+ const arrayBuffer = await exportResponse.arrayBuffer()
+ if (arrayBuffer.byteLength > MAX_EXPORT_BYTES) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Exported content (${arrayBuffer.byteLength} bytes) exceeds the ${MAX_EXPORT_BYTES}-byte export limit.`,
+ },
+ { status: 400 }
+ )
+ }
+ const fileBuffer = Buffer.from(arrayBuffer)
+
+ const resolvedName = fileName || metadata.name || 'export'
+
+ logger.info(`[${requestId}] File exported successfully`, {
+ fileId,
+ name: resolvedName,
+ size: fileBuffer.length,
+ mimeType: exportMimeType,
+ })
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ file: {
+ name: resolvedName,
+ mimeType: exportMimeType,
+ data: fileBuffer.toString('base64'),
+ size: fileBuffer.length,
+ },
+ exportedMimeType: exportMimeType,
+ },
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Error exporting Google Drive file:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Unknown error occurred') },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts
index 9c0cd1ccd9f..0600386324e 100644
--- a/apps/sim/app/api/tools/google_drive/upload/route.ts
+++ b/apps/sim/app/api/tools/google_drive/upload/route.ts
@@ -8,7 +8,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
GOOGLE_WORKSPACE_MIME_TYPES,
@@ -120,10 +121,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (denied) return denied
let fileBuffer: Buffer
+ let downloadedContentType = ''
try {
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ const result = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = result.buffer
+ downloadedContentType = result.contentType
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Failed to download file:`, error)
return NextResponse.json(
{
@@ -134,8 +140,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- let uploadMimeType = validatedData.mimeType || userFile.type || 'application/octet-stream'
- const requestedMimeType = validatedData.mimeType || userFile.type || 'application/octet-stream'
+ let uploadMimeType =
+ validatedData.mimeType || downloadedContentType || userFile.type || 'application/octet-stream'
+ const requestedMimeType =
+ validatedData.mimeType || downloadedContentType || userFile.type || 'application/octet-stream'
if (GOOGLE_WORKSPACE_MIME_TYPES.includes(requestedMimeType)) {
uploadMimeType = SOURCE_MIME_TYPES[requestedMimeType] || 'text/plain'
diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts
index 2fc2e89f2a7..b23abf6656b 100644
--- a/apps/sim/app/api/tools/jira/add-attachment/route.ts
+++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts
@@ -6,7 +6,8 @@ import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils'
@@ -47,9 +48,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
for (const file of userFiles) {
const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(file, requestId, logger)
+ let buffer: Buffer
+ let downloadedContentType = ''
+ try {
+ const result = await downloadServableFileFromStorage(file, requestId, logger)
+ buffer = result.buffer
+ downloadedContentType = result.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ throw error
+ }
const blob = new Blob([new Uint8Array(buffer)], {
- type: file.type || 'application/octet-stream',
+ type: downloadedContentType || file.type || 'application/octet-stream',
})
formData.append('file', blob, file.name)
}
diff --git a/apps/sim/app/api/tools/linq/upload/route.ts b/apps/sim/app/api/tools/linq/upload/route.ts
index 379b7665c0b..6b0004df4e2 100644
--- a/apps/sim/app/api/tools/linq/upload/route.ts
+++ b/apps/sim/app/api/tools/linq/upload/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { extractLinqError, LINQ_API_BASE, linqHeaders } from '@/tools/linq/utils'
@@ -58,9 +59,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const userFile = userFiles[0]
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let resolvedContentTypeFromStorage: string
+ try {
+ const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
+ buffer = resolved.buffer
+ resolvedContentTypeFromStorage = resolved.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download Linq attachment file:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Unknown error occurred') },
+ { status: 500 }
+ )
+ }
if (!resolvedFilename) resolvedFilename = userFile.name
- if (!resolvedContentType) resolvedContentType = userFile.type || 'application/octet-stream'
+ if (!resolvedContentType)
+ resolvedContentType =
+ resolvedContentTypeFromStorage || userFile.type || 'application/octet-stream'
} else if (fileContent) {
buffer = Buffer.from(fileContent, 'base64')
if (!resolvedFilename) resolvedFilename = 'file'
diff --git a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts
index dbf5b10093b..21a767f4a3e 100644
--- a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts
+++ b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts
@@ -8,7 +8,8 @@ import { secureFetchWithValidation } from '@/lib/core/security/input-validation.
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -71,7 +72,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ try {
+ const servable = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = servable.buffer
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download file from storage:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to download file') },
+ { status: 500 }
+ )
+ }
} else if (validatedData.fileContent) {
fileBuffer = Buffer.from(validatedData.fileContent, 'base64')
} else {
diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts
index 58fb3cfd94a..cdce8f5f38e 100644
--- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts
+++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts
@@ -7,6 +7,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { FileAccessDeniedError } from '@/app/api/files/authorization'
import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils'
import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types'
@@ -167,6 +168,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (error instanceof FileAccessDeniedError) {
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Error sending Teams channel message:`, error)
return NextResponse.json(
{
diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts
index 96a4dada98c..b4af6be45df 100644
--- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts
+++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts
@@ -7,6 +7,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { FileAccessDeniedError } from '@/app/api/files/authorization'
import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils'
import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types'
@@ -164,6 +165,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (error instanceof FileAccessDeniedError) {
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Error sending Teams chat message:`, error)
return NextResponse.json(
{
diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts
index acfd066879a..f9ec00f344e 100644
--- a/apps/sim/app/api/tools/mistral/parse/route.ts
+++ b/apps/sim/app/api/tools/mistral/parse/route.ts
@@ -12,9 +12,10 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import {
- downloadFileFromStorage,
+ downloadServableFileFromStorage,
resolveInternalFileUrl,
} from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -124,8 +125,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!base64) {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ const { buffer, contentType } = await downloadServableFileFromStorage(
+ userFile,
+ requestId,
+ logger
+ )
base64 = buffer.toString('base64')
+ if (contentType && contentType !== 'application/octet-stream') {
+ mimeType = contentType
+ }
}
const base64Payload = base64.startsWith('data:')
? base64
@@ -265,6 +273,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
output: mistralData,
})
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+
logger.error(`[${requestId}] Error in Mistral parse:`, error)
return NextResponse.json(
diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts
index f27cc9aa4bc..2c94986de9f 100644
--- a/apps/sim/app/api/tools/onedrive/upload/route.ts
+++ b/apps/sim/app/api/tools/onedrive/upload/route.ts
@@ -13,7 +13,8 @@ import {
getExtensionFromMimeType,
processSingleFileToUserFile,
} from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { normalizeExcelValues } from '@/tools/onedrive/utils'
@@ -114,8 +115,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (denied) return denied
try {
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ const result = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = result.buffer
+ mimeType = result.contentType || userFile.type || 'application/octet-stream'
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Failed to download file from storage:`, error)
return NextResponse.json(
{
@@ -125,8 +130,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 500 }
)
}
-
- mimeType = userFile.type || 'application/octet-stream'
}
const maxSize = 250 * 1024 * 1024
diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts
index 693f0fa2ad5..979634ffcf3 100644
--- a/apps/sim/app/api/tools/outlook/draft/route.ts
+++ b/apps/sim/app/api/tools/outlook/draft/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -107,27 +108,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- attachments.map(async (file) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ attachments.map(async (file) => {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0)
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Total attachment size (${sizeMB}MB) exceeds Outlook's limit of 4MB per request`,
+ },
+ { status: 400 }
+ )
+ }
const attachmentObjects = attachments.map((file, i) => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: file.name,
- contentType: file.type || 'application/octet-stream',
- contentBytes: buffers[i].toString('base64'),
+ contentType: resolved[i].contentType || file.type || 'application/octet-stream',
+ contentBytes: resolved[i].buffer.toString('base64'),
}))
logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`)
diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts
index 080d9db4871..7a59f44410a 100644
--- a/apps/sim/app/api/tools/outlook/send/route.ts
+++ b/apps/sim/app/api/tools/outlook/send/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -107,27 +108,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- attachments.map(async (file) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ attachments.map(async (file) => {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0)
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Total attachment size (${sizeMB}MB) exceeds Microsoft Graph API limit of 3MB per request`,
+ },
+ { status: 400 }
+ )
+ }
const attachmentObjects = attachments.map((file, i) => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: file.name,
- contentType: file.type || 'application/octet-stream',
- contentBytes: buffers[i].toString('base64'),
+ contentType: resolved[i].contentType || file.type || 'application/octet-stream',
+ contentBytes: resolved[i].buffer.toString('base64'),
}))
logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`)
diff --git a/apps/sim/app/api/tools/persona/import-accounts/route.ts b/apps/sim/app/api/tools/persona/import-accounts/route.ts
index 8837881b990..0844b11c16a 100644
--- a/apps/sim/app/api/tools/persona/import-accounts/route.ts
+++ b/apps/sim/app/api/tools/persona/import-accounts/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess, FileAccessDeniedError } from '@/app/api/files/authorization'
import {
buildPersonaHeaders,
@@ -55,7 +56,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let buffer: Buffer
+ try {
+ const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
+ buffer = resolved.buffer
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download Persona import file:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Internal server error') },
+ { status: 500 }
+ )
+ }
logger.info(`[${requestId}] Importing accounts into Persona`, {
fileName: userFile.name,
diff --git a/apps/sim/app/api/tools/s3/create-bucket/route.ts b/apps/sim/app/api/tools/s3/create-bucket/route.ts
new file mode 100644
index 00000000000..055ee8bf509
--- /dev/null
+++ b/apps/sim/app/api/tools/s3/create-bucket/route.ts
@@ -0,0 +1,99 @@
+import {
+ type BucketCannedACL,
+ type BucketLocationConstraint,
+ CreateBucketCommand,
+ S3Client,
+} from '@aws-sdk/client-s3'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { awsS3CreateBucketContract } from '@/lib/api/contracts/tools/aws/s3-create-bucket'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('S3CreateBucketAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized S3 create bucket attempt: ${authResult.error}`)
+ return NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ },
+ { status: 401 }
+ )
+ }
+
+ logger.info(
+ `[${requestId}] Authenticated S3 create bucket request via ${authResult.authType}`,
+ {
+ userId: authResult.userId,
+ }
+ )
+
+ const parsed = await parseToolRequest(awsS3CreateBucketContract, request, {
+ errorFormat: 'details',
+ logger,
+ })
+ if (!parsed.success) return parsed.response
+ const validatedData = parsed.data.body
+
+ logger.info(`[${requestId}] Creating S3 bucket`, {
+ bucket: validatedData.bucketName,
+ region: validatedData.region,
+ })
+
+ const s3Client = new S3Client({
+ region: validatedData.region,
+ credentials: {
+ accessKeyId: validatedData.accessKeyId,
+ secretAccessKey: validatedData.secretAccessKey,
+ },
+ })
+
+ const createCommand = new CreateBucketCommand({
+ Bucket: validatedData.bucketName,
+ ACL: (validatedData.acl as BucketCannedACL | undefined) || undefined,
+ CreateBucketConfiguration:
+ validatedData.region === 'us-east-1'
+ ? undefined
+ : { LocationConstraint: validatedData.region as BucketLocationConstraint },
+ })
+
+ const result = await s3Client.send(createCommand)
+
+ logger.info(`[${requestId}] Bucket created successfully`, {
+ bucket: validatedData.bucketName,
+ location: result.Location,
+ })
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ bucket: validatedData.bucketName,
+ location: result.Location ?? null,
+ bucketArn: result.BucketArn ?? null,
+ },
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Error creating S3 bucket:`, error)
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: getErrorMessage(error, 'Internal server error'),
+ },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/s3/delete-bucket/route.ts b/apps/sim/app/api/tools/s3/delete-bucket/route.ts
new file mode 100644
index 00000000000..c3d89e92cd2
--- /dev/null
+++ b/apps/sim/app/api/tools/s3/delete-bucket/route.ts
@@ -0,0 +1,86 @@
+import { DeleteBucketCommand, S3Client } from '@aws-sdk/client-s3'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { awsS3DeleteBucketContract } from '@/lib/api/contracts/tools/aws/s3-delete-bucket'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('S3DeleteBucketAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized S3 delete bucket attempt: ${authResult.error}`)
+ return NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ },
+ { status: 401 }
+ )
+ }
+
+ logger.info(
+ `[${requestId}] Authenticated S3 delete bucket request via ${authResult.authType}`,
+ {
+ userId: authResult.userId,
+ }
+ )
+
+ const parsed = await parseToolRequest(awsS3DeleteBucketContract, request, {
+ errorFormat: 'details',
+ logger,
+ })
+ if (!parsed.success) return parsed.response
+ const validatedData = parsed.data.body
+
+ logger.info(`[${requestId}] Deleting S3 bucket`, {
+ bucket: validatedData.bucketName,
+ })
+
+ const s3Client = new S3Client({
+ region: validatedData.region,
+ credentials: {
+ accessKeyId: validatedData.accessKeyId,
+ secretAccessKey: validatedData.secretAccessKey,
+ },
+ })
+
+ const deleteCommand = new DeleteBucketCommand({
+ Bucket: validatedData.bucketName,
+ })
+
+ await s3Client.send(deleteCommand)
+
+ logger.info(`[${requestId}] Bucket deleted successfully`, {
+ bucket: validatedData.bucketName,
+ })
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ deleted: true,
+ bucket: validatedData.bucketName,
+ },
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting S3 bucket:`, error)
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: getErrorMessage(error, 'Internal server error'),
+ },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/s3/delete-objects/route.ts b/apps/sim/app/api/tools/s3/delete-objects/route.ts
new file mode 100644
index 00000000000..f8b74278427
--- /dev/null
+++ b/apps/sim/app/api/tools/s3/delete-objects/route.ts
@@ -0,0 +1,105 @@
+import { DeleteObjectsCommand, S3Client } from '@aws-sdk/client-s3'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { awsS3DeleteObjectsContract } from '@/lib/api/contracts/tools/aws/s3-delete-objects'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('S3DeleteObjectsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized S3 delete objects attempt: ${authResult.error}`)
+ return NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ },
+ { status: 401 }
+ )
+ }
+
+ logger.info(
+ `[${requestId}] Authenticated S3 delete objects request via ${authResult.authType}`,
+ {
+ userId: authResult.userId,
+ }
+ )
+
+ const parsed = await parseToolRequest(awsS3DeleteObjectsContract, request, {
+ errorFormat: 'details',
+ logger,
+ })
+ if (!parsed.success) return parsed.response
+ const validatedData = parsed.data.body
+
+ logger.info(`[${requestId}] Deleting S3 objects`, {
+ bucket: validatedData.bucketName,
+ count: validatedData.keys.length,
+ })
+
+ const s3Client = new S3Client({
+ region: validatedData.region,
+ credentials: {
+ accessKeyId: validatedData.accessKeyId,
+ secretAccessKey: validatedData.secretAccessKey,
+ },
+ })
+
+ const deleteCommand = new DeleteObjectsCommand({
+ Bucket: validatedData.bucketName,
+ Delete: {
+ Objects: validatedData.keys.map((key) => ({ Key: key })),
+ Quiet: validatedData.quiet ?? false,
+ },
+ })
+
+ const result = await s3Client.send(deleteCommand)
+
+ const deleted = (result.Deleted || []).map((obj) => ({
+ key: obj.Key ?? null,
+ versionId: obj.VersionId ?? null,
+ deleteMarker: obj.DeleteMarker ?? null,
+ }))
+
+ const errors = (result.Errors || []).map((err) => ({
+ key: err.Key ?? null,
+ code: err.Code ?? null,
+ message: err.Message ?? null,
+ }))
+
+ logger.info(`[${requestId}] Delete objects completed`, {
+ bucket: validatedData.bucketName,
+ deleted: deleted.length,
+ errors: errors.length,
+ })
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ deleted,
+ errors,
+ },
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Error deleting S3 objects:`, error)
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: getErrorMessage(error, 'Internal server error'),
+ },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/s3/head-object/route.ts b/apps/sim/app/api/tools/s3/head-object/route.ts
new file mode 100644
index 00000000000..898a29c7167
--- /dev/null
+++ b/apps/sim/app/api/tools/s3/head-object/route.ts
@@ -0,0 +1,114 @@
+import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { awsS3HeadObjectContract } from '@/lib/api/contracts/tools/aws/s3-head-object'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('S3HeadObjectAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized S3 head object attempt: ${authResult.error}`)
+ return NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ },
+ { status: 401 }
+ )
+ }
+
+ logger.info(`[${requestId}] Authenticated S3 head object request via ${authResult.authType}`, {
+ userId: authResult.userId,
+ })
+
+ const parsed = await parseToolRequest(awsS3HeadObjectContract, request, {
+ errorFormat: 'details',
+ logger,
+ })
+ if (!parsed.success) return parsed.response
+ const validatedData = parsed.data.body
+
+ logger.info(`[${requestId}] Fetching S3 object metadata`, {
+ bucket: validatedData.bucketName,
+ key: validatedData.objectKey,
+ })
+
+ const s3Client = new S3Client({
+ region: validatedData.region,
+ credentials: {
+ accessKeyId: validatedData.accessKeyId,
+ secretAccessKey: validatedData.secretAccessKey,
+ },
+ })
+
+ const headCommand = new HeadObjectCommand({
+ Bucket: validatedData.bucketName,
+ Key: validatedData.objectKey,
+ VersionId: validatedData.versionId || undefined,
+ })
+
+ const result = await s3Client.send(headCommand)
+
+ logger.info(`[${requestId}] Object metadata retrieved`, {
+ bucket: validatedData.bucketName,
+ key: validatedData.objectKey,
+ })
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ exists: true,
+ contentLength: result.ContentLength ?? null,
+ contentType: result.ContentType ?? null,
+ etag: result.ETag ?? null,
+ lastModified: result.LastModified?.toISOString() ?? null,
+ versionId: result.VersionId ?? null,
+ storageClass: result.StorageClass ?? null,
+ serverSideEncryption: result.ServerSideEncryption ?? null,
+ deleteMarker: result.DeleteMarker ?? null,
+ metadata: result.Metadata ?? {},
+ },
+ })
+ } catch (error) {
+ const metadata = error as { name?: string; $metadata?: { httpStatusCode?: number } }
+ if (metadata?.name === 'NotFound' || metadata?.$metadata?.httpStatusCode === 404) {
+ return NextResponse.json({
+ success: true,
+ output: {
+ exists: false,
+ contentLength: null,
+ contentType: null,
+ etag: null,
+ lastModified: null,
+ versionId: null,
+ storageClass: null,
+ serverSideEncryption: null,
+ deleteMarker: null,
+ metadata: {},
+ },
+ })
+ }
+
+ logger.error(`[${requestId}] Error fetching S3 object metadata:`, error)
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: getErrorMessage(error, 'Internal server error'),
+ },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/s3/list-buckets/route.ts b/apps/sim/app/api/tools/s3/list-buckets/route.ts
new file mode 100644
index 00000000000..1a951bf5632
--- /dev/null
+++ b/apps/sim/app/api/tools/s3/list-buckets/route.ts
@@ -0,0 +1,97 @@
+import { ListBucketsCommand, S3Client } from '@aws-sdk/client-s3'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { awsS3ListBucketsContract } from '@/lib/api/contracts/tools/aws/s3-list-buckets'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('S3ListBucketsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized S3 list buckets attempt: ${authResult.error}`)
+ return NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ },
+ { status: 401 }
+ )
+ }
+
+ logger.info(`[${requestId}] Authenticated S3 list buckets request via ${authResult.authType}`, {
+ userId: authResult.userId,
+ })
+
+ const parsed = await parseToolRequest(awsS3ListBucketsContract, request, {
+ errorFormat: 'details',
+ logger,
+ })
+ if (!parsed.success) return parsed.response
+ const validatedData = parsed.data.body
+
+ logger.info(`[${requestId}] Listing S3 buckets`, {
+ prefix: validatedData.prefix || '(none)',
+ maxBuckets: validatedData.maxBuckets || '(all)',
+ })
+
+ const s3Client = new S3Client({
+ region: validatedData.region,
+ credentials: {
+ accessKeyId: validatedData.accessKeyId,
+ secretAccessKey: validatedData.secretAccessKey,
+ },
+ })
+
+ const listCommand = new ListBucketsCommand({
+ Prefix: validatedData.prefix || undefined,
+ MaxBuckets: validatedData.maxBuckets || undefined,
+ ContinuationToken: validatedData.continuationToken || undefined,
+ })
+
+ const result = await s3Client.send(listCommand)
+
+ const buckets = (result.Buckets || []).map((bucket) => ({
+ name: bucket.Name || '',
+ creationDate: bucket.CreationDate?.toISOString() ?? null,
+ region: bucket.BucketRegion ?? null,
+ }))
+
+ logger.info(`[${requestId}] Listed ${buckets.length} buckets`)
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ buckets,
+ owner: result.Owner
+ ? {
+ displayName: result.Owner.DisplayName ?? null,
+ id: result.Owner.ID ?? null,
+ }
+ : null,
+ continuationToken: result.ContinuationToken ?? null,
+ prefix: result.Prefix ?? null,
+ },
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Error listing S3 buckets:`, error)
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: getErrorMessage(error, 'Internal server error'),
+ },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/s3/presigned-url/route.ts b/apps/sim/app/api/tools/s3/presigned-url/route.ts
new file mode 100644
index 00000000000..72b443d974d
--- /dev/null
+++ b/apps/sim/app/api/tools/s3/presigned-url/route.ts
@@ -0,0 +1,105 @@
+import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
+import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { awsS3PresignedUrlContract } from '@/lib/api/contracts/tools/aws/s3-presigned-url'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+export const dynamic = 'force-dynamic'
+
+const logger = createLogger('S3PresignedUrlAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized S3 presigned URL attempt: ${authResult.error}`)
+ return NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ },
+ { status: 401 }
+ )
+ }
+
+ logger.info(
+ `[${requestId}] Authenticated S3 presigned URL request via ${authResult.authType}`,
+ {
+ userId: authResult.userId,
+ }
+ )
+
+ const parsed = await parseToolRequest(awsS3PresignedUrlContract, request, {
+ errorFormat: 'details',
+ logger,
+ })
+ if (!parsed.success) return parsed.response
+ const validatedData = parsed.data.body
+
+ logger.info(`[${requestId}] Generating S3 presigned URL`, {
+ bucket: validatedData.bucketName,
+ key: validatedData.objectKey,
+ method: validatedData.method,
+ expiresIn: validatedData.expiresIn,
+ })
+
+ const s3Client = new S3Client({
+ region: validatedData.region,
+ credentials: {
+ accessKeyId: validatedData.accessKeyId,
+ secretAccessKey: validatedData.secretAccessKey,
+ },
+ })
+
+ const command =
+ validatedData.method === 'put'
+ ? new PutObjectCommand({
+ Bucket: validatedData.bucketName,
+ Key: validatedData.objectKey,
+ ContentType: validatedData.contentType || undefined,
+ })
+ : new GetObjectCommand({
+ Bucket: validatedData.bucketName,
+ Key: validatedData.objectKey,
+ })
+
+ const url = await getSignedUrl(s3Client, command, {
+ expiresIn: validatedData.expiresIn,
+ })
+
+ const expiresAt = new Date(Date.now() + validatedData.expiresIn * 1000).toISOString()
+
+ logger.info(`[${requestId}] Presigned URL generated`, {
+ bucket: validatedData.bucketName,
+ key: validatedData.objectKey,
+ })
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ url,
+ method: validatedData.method,
+ expiresIn: validatedData.expiresIn,
+ expiresAt,
+ },
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Error generating S3 presigned URL:`, error)
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: getErrorMessage(error, 'Internal server error'),
+ },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts
index 13d543aca0a..2019de2b4d4 100644
--- a/apps/sim/app/api/tools/s3/put-object/route.ts
+++ b/apps/sim/app/api/tools/s3/put-object/route.ts
@@ -8,7 +8,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -81,10 +82,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let downloadedContentType = ''
+ try {
+ const result = await downloadServableFileFromStorage(userFile, requestId, logger)
+ uploadBody = result.buffer
+ downloadedContentType = result.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to download file') },
+ { status: 500 }
+ )
+ }
- uploadBody = buffer
- uploadContentType = validatedData.contentType || userFile.type || 'application/octet-stream'
+ uploadContentType =
+ validatedData.contentType ||
+ downloadedContentType ||
+ userFile.type ||
+ 'application/octet-stream'
} else if (validatedData.content) {
uploadBody = Buffer.from(validatedData.content, 'utf-8')
uploadContentType = validatedData.contentType || 'text/plain'
diff --git a/apps/sim/app/api/tools/sap_concur/upload/route.ts b/apps/sim/app/api/tools/sap_concur/upload/route.ts
index 74f8fb093de..4a682b3c5d7 100644
--- a/apps/sim/app/api/tools/sap_concur/upload/route.ts
+++ b/apps/sim/app/api/tools/sap_concur/upload/route.ts
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
+import { getErrorMessage, toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { getValidationErrorMessage, isZodError } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
@@ -7,7 +7,8 @@ import { secureFetchWithValidation } from '@/lib/core/security/input-validation.
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
assertSafeExternalUrl,
@@ -208,9 +209,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const userFile = userFiles[0]
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
- const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let fileBuffer: Buffer
+ let resolvedContentType: string
+ try {
+ const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = resolved.buffer
+ resolvedContentType = resolved.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download Concur receipt file:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Unknown error') },
+ { status: 500 }
+ )
+ }
const fileName = userFile.name
- const mimeType = inferMimeType(fileName, userFile.type)
+ const mimeType = inferMimeType(fileName, resolvedContentType || userFile.type)
const allowedForOperation =
uploadReq.operation === 'create_quick_expense_with_image'
diff --git a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts
index b70144b1956..d9b2b97876a 100644
--- a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts
+++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -106,26 +107,46 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- userFiles.map(async (file) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ userFiles.map(async (file) => {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0)
+ const maxSize = 30 * 1024 * 1024
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Total attachment size (${sizeMB}MB) exceeds SendGrid's limit of 30MB`,
+ },
+ { status: 400 }
+ )
+ }
const sendGridAttachments = userFiles.map((file, i) => ({
- content: buffers[i].toString('base64'),
+ content: resolved[i].buffer.toString('base64'),
filename: file.name,
- type: file.type || 'application/octet-stream',
+ type: resolved[i].contentType || file.type || 'application/octet-stream',
disposition: 'attachment',
}))
diff --git a/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts b/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts
index 4ddd8d162fb..78dcdb0883b 100644
--- a/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts
+++ b/apps/sim/app/api/tools/servicenow/upload-attachment/route.ts
@@ -8,7 +8,8 @@ import { secureFetchWithValidation } from '@/lib/core/security/input-validation.
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import type { ServiceNowAttachment } from '@/tools/servicenow/types'
import { createBasicAuthHeader } from '@/tools/servicenow/utils'
@@ -52,8 +53,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- const contentType = userFile.type || 'application/octet-stream'
- const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let fileBuffer: Buffer
+ let resolvedContentType: string
+ try {
+ const servable = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = servable.buffer
+ resolvedContentType = servable.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download file from storage:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to download file') },
+ { status: 500 }
+ )
+ }
+
+ const contentType = resolvedContentType || userFile.type || 'application/octet-stream'
const baseUrl = body.instanceUrl.trim().replace(/\/$/, '')
const uploadParams = new URLSearchParams({
diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts
index 6b686d57c2b..09029d5cd81 100644
--- a/apps/sim/app/api/tools/sftp/upload/route.ts
+++ b/apps/sim/app/api/tools/sftp/upload/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
createSftpConnection,
@@ -95,6 +96,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
+ let resolvedTotal = 0
for (const file of userFiles) {
try {
const denied = await assertToolFileAccess(
@@ -107,7 +109,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
logger.info(
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
)
- const buffer = await downloadFileFromStorage(file, requestId, logger)
+ const { buffer } = await downloadServableFileFromStorage(file, requestId, logger)
+
+ resolvedTotal += buffer.length
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ { success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` },
+ { status: 400 }
+ )
+ }
const safeFileName = sanitizeFileName(file.name)
const fullRemotePath = remotePath.endsWith('/')
@@ -142,6 +153,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
logger.info(`[${requestId}] Uploaded ${safeFileName} to ${sanitizedRemotePath}`)
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Failed to upload file ${file.name}:`, error)
throw new Error(
`Failed to upload file "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts
index af975058eb1..3c85a7bd7d1 100644
--- a/apps/sim/app/api/tools/sharepoint/upload/route.ts
+++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts
@@ -8,7 +8,8 @@ import { secureFetchWithValidation } from '@/lib/core/security/input-validation.
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types'
import type { SharepointSkippedFile, SharepointUploadError } from '@/tools/sharepoint/types'
@@ -87,7 +88,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (denied) return denied
logger.info(`[${requestId}] Uploading file: ${userFile.name}`)
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let buffer: Buffer
+ let downloadedContentType = ''
+ try {
+ const result = await downloadServableFileFromStorage(userFile, requestId, logger)
+ buffer = result.buffer
+ downloadedContentType = result.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ throw error
+ }
const fileName = validatedData.fileName || userFile.name
const folderPath = validatedData.folderPath?.trim() || ''
@@ -135,7 +146,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'PUT',
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
- 'Content-Type': userFile.type || 'application/octet-stream',
+ 'Content-Type': downloadedContentType || userFile.type || 'application/octet-stream',
},
body: buffer,
},
@@ -156,7 +167,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
method: 'PUT',
headers: {
Authorization: `Bearer ${validatedData.accessToken}`,
- 'Content-Type': userFile.type || 'application/octet-stream',
+ 'Content-Type':
+ downloadedContentType || userFile.type || 'application/octet-stream',
},
body: buffer,
},
diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts
index a407df3bbb6..8a972fd4c85 100644
--- a/apps/sim/app/api/tools/slack/send-message/route.ts
+++ b/apps/sim/app/api/tools/slack/send-message/route.ts
@@ -6,6 +6,7 @@ import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { FileAccessDeniedError } from '@/app/api/files/authorization'
import { sendSlackMessage } from '@/app/api/tools/slack/utils'
@@ -72,6 +73,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (error instanceof FileAccessDeniedError) {
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Error sending Slack message:`, error)
return NextResponse.json(
{
diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts
index 91b6fc14534..b3ff2205806 100644
--- a/apps/sim/app/api/tools/slack/utils.ts
+++ b/apps/sim/app/api/tools/slack/utils.ts
@@ -1,7 +1,7 @@
import type { Logger } from '@sim/logger'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization'
import type { ToolFileData } from '@/tools/types'
@@ -89,7 +89,11 @@ async function uploadFilesToSlack(
throw new FileAccessDeniedError()
}
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ const { buffer, contentType } = await downloadServableFileFromStorage(
+ userFile,
+ requestId,
+ logger
+ )
const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', {
method: 'POST',
@@ -131,7 +135,7 @@ async function uploadFilesToSlack(
// Only add to uploadedFiles after successful upload to keep arrays in sync
uploadedFiles.push({
name: userFile.name,
- mimeType: userFile.type || 'application/octet-stream',
+ mimeType: contentType || userFile.type || 'application/octet-stream',
data: buffer.toString('base64'),
size: buffer.length,
})
diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts
index e08101c8596..be1c4371fb6 100644
--- a/apps/sim/app/api/tools/smtp/send/route.ts
+++ b/apps/sim/app/api/tools/smtp/send/route.ts
@@ -9,7 +9,8 @@ import { validateDatabaseHost } from '@/lib/core/security/input-validation.serve
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -127,26 +128,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = accessResults.find((r) => r !== null)
if (denied) return denied
- const buffers = await Promise.all(
- attachments.map(async (file) => {
- try {
+ let resolved: Array<{ buffer: Buffer; contentType: string }>
+ try {
+ resolved = await Promise.all(
+ attachments.map(async (file) => {
logger.info(
`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`
)
- return await downloadFileFromStorage(file, requestId, logger)
- } catch (error) {
- logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error)
- throw new Error(
- `Failed to download attachment "${file.name}": ${getErrorMessage(error, 'Unknown error')}`
- )
- }
- })
- )
+ return await downloadServableFileFromStorage(file, requestId, logger)
+ })
+ )
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download an attachment:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download attachment: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ const resolvedTotal = resolved.reduce((sum, r) => sum + r.buffer.length, 0)
+ if (resolvedTotal > maxSize) {
+ const sizeMB = (resolvedTotal / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Total attachment size (${sizeMB}MB) exceeds SMTP limit of 25MB`,
+ },
+ { status: 400 }
+ )
+ }
const attachmentBuffers = attachments.map((file, i) => ({
filename: file.name,
- content: buffers[i],
- contentType: file.type || 'application/octet-stream',
+ content: resolved[i].buffer,
+ contentType: resolved[i].contentType || file.type || 'application/octet-stream',
}))
logger.info(`[${requestId}] Processed ${attachmentBuffers.length} attachment(s)`)
diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts
index 9a3a2643c58..3e2dcd4cdd6 100644
--- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts
+++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts
@@ -8,7 +8,8 @@ import { validateSupabaseProjectId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -147,10 +148,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let buffer: Buffer
+ let resolvedContentType: string
+ try {
+ const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
+ buffer = resolved.buffer
+ resolvedContentType = resolved.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download file for Supabase upload:`, error)
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Internal server error') },
+ { status: 500 }
+ )
+ }
uploadBody = buffer
- uploadContentType = validatedData.contentType || userFile.type || 'application/octet-stream'
+ uploadContentType =
+ validatedData.contentType ||
+ resolvedContentType ||
+ userFile.type ||
+ 'application/octet-stream'
}
let fullPath = validatedData.fileName
diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts
index 2d685c501fd..f454717e290 100644
--- a/apps/sim/app/api/tools/telegram/send-document/route.ts
+++ b/apps/sim/app/api/tools/telegram/send-document/route.ts
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { convertMarkdownToHTML } from '@/tools/telegram/utils'
@@ -93,11 +94,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ let buffer: Buffer
+ let contentType: string
+ try {
+ const downloaded = await downloadServableFileFromStorage(userFile, requestId, logger)
+ buffer = downloaded.buffer
+ contentType = downloaded.contentType
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download document ${userFile.name}:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Failed to download document: ${getErrorMessage(error, 'Unknown error')}`,
+ },
+ { status: 500 }
+ )
+ }
+
+ if (buffer.length > maxSize) {
+ const sizeMB = (buffer.length / (1024 * 1024)).toFixed(2)
+ return NextResponse.json(
+ {
+ success: false,
+ error: `The following files exceed Telegram's 50MB limit: ${userFile.name} (${sizeMB}MB)`,
+ },
+ { status: 400 }
+ )
+ }
+
+ const resolvedMimeType = contentType || userFile.type || 'application/octet-stream'
const filesOutput = [
{
name: userFile.name,
- mimeType: userFile.type || 'application/octet-stream',
+ mimeType: resolvedMimeType,
data: buffer.toString('base64'),
size: buffer.length,
},
@@ -108,7 +139,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const formData = new FormData()
formData.append('chat_id', validatedData.chatId)
- const blob = new Blob([new Uint8Array(buffer)], { type: userFile.type })
+ const blob = new Blob([new Uint8Array(buffer)], { type: resolvedMimeType })
formData.append('document', blob, userFile.name)
if (validatedData.caption) {
diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts
index b93cbbed4d9..82eb65ff830 100644
--- a/apps/sim/app/api/tools/textract/parse/route.ts
+++ b/apps/sim/app/api/tools/textract/parse/route.ts
@@ -16,9 +16,10 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import {
- downloadFileFromStorage,
+ downloadServableFileFromStorage,
resolveInternalFileUrl,
} from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -437,9 +438,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
+ const { buffer, contentType: resolvedContentType } = await downloadServableFileFromStorage(
+ userFile,
+ requestId,
+ logger
+ )
bytes = buffer.toString('base64')
- contentType = userFile.type || 'application/octet-stream'
+ contentType = resolvedContentType || userFile.type || 'application/octet-stream'
isPdf = contentType.includes('pdf') || userFile.name?.toLowerCase().endsWith('.pdf')
} else if (validatedData.filePath) {
let fileUrl = validatedData.filePath
@@ -616,6 +621,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
},
})
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+
logger.error(`[${requestId}] Error in Textract parse:`, error)
return NextResponse.json(
diff --git a/apps/sim/app/api/tools/uptimerobot/create-psp/route.ts b/apps/sim/app/api/tools/uptimerobot/create-psp/route.ts
index 0b0036711ec..1d879c5735b 100644
--- a/apps/sim/app/api/tools/uptimerobot/create-psp/route.ts
+++ b/apps/sim/app/api/tools/uptimerobot/create-psp/route.ts
@@ -6,6 +6,7 @@ import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { forwardPspRequest } from '@/app/api/tools/uptimerobot/server-utils'
export const dynamic = 'force-dynamic'
@@ -39,6 +40,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
logger,
})
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Unexpected error creating status page:`, error)
return NextResponse.json(
{ success: false, error: getErrorMessage(error, 'Unknown error') },
diff --git a/apps/sim/app/api/tools/uptimerobot/server-utils.ts b/apps/sim/app/api/tools/uptimerobot/server-utils.ts
index f94d66c0948..d7a1ef9ea8c 100644
--- a/apps/sim/app/api/tools/uptimerobot/server-utils.ts
+++ b/apps/sim/app/api/tools/uptimerobot/server-utils.ts
@@ -1,7 +1,7 @@
import type { Logger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { mapPsp, UPTIMEROBOT_API_BASE } from '@/tools/uptimerobot/types'
@@ -47,8 +47,8 @@ async function appendPspImage(
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return denied
- const buffer = await downloadFileFromStorage(userFile, requestId, logger)
- const mimeType = userFile.type || 'application/octet-stream'
+ const { buffer, contentType } = await downloadServableFileFromStorage(userFile, requestId, logger)
+ const mimeType = contentType || userFile.type || 'application/octet-stream'
form.append(field, new Blob([new Uint8Array(buffer)], { type: mimeType }), userFile.name)
return null
}
diff --git a/apps/sim/app/api/tools/uptimerobot/update-psp/route.ts b/apps/sim/app/api/tools/uptimerobot/update-psp/route.ts
index a3787686b9a..063a4252ca6 100644
--- a/apps/sim/app/api/tools/uptimerobot/update-psp/route.ts
+++ b/apps/sim/app/api/tools/uptimerobot/update-psp/route.ts
@@ -6,6 +6,7 @@ import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { forwardPspRequest } from '@/app/api/tools/uptimerobot/server-utils'
export const dynamic = 'force-dynamic'
@@ -39,6 +40,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
logger,
})
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Unexpected error updating status page:`, error)
return NextResponse.json(
{ success: false, error: getErrorMessage(error, 'Unknown error') },
diff --git a/apps/sim/app/api/tools/vanta/upload/route.ts b/apps/sim/app/api/tools/vanta/upload/route.ts
index a76cc848c01..81785b515c8 100644
--- a/apps/sim/app/api/tools/vanta/upload/route.ts
+++ b/apps/sim/app/api/tools/vanta/upload/route.ts
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
+import { getErrorMessage, toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { vantaUploadContract } from '@/lib/api/contracts/tools/vanta'
import { parseRequest } from '@/lib/api/server'
@@ -7,7 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import {
asVantaRecord,
@@ -70,9 +71,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return uploadSizeError(userFile.size)
}
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
- fileName = params.fileName || userFile.name
- mimeType = userFile.type || params.mimeType || 'application/octet-stream'
+ try {
+ const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = resolved.buffer
+ fileName = params.fileName || userFile.name
+ mimeType =
+ resolved.contentType || userFile.type || params.mimeType || 'application/octet-stream'
+ } catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
+ logger.error(`[${requestId}] Failed to download Vanta upload file`, {
+ error: getErrorMessage(error),
+ })
+ return NextResponse.json(
+ { success: false, error: getErrorMessage(error, 'Failed to download file') },
+ { status: 500 }
+ )
+ }
} else if (params.fileContent) {
fileBuffer = Buffer.from(params.fileContent, 'base64')
fileName = params.fileName || 'file'
diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts
index a62632caea6..aac9e9e3354 100644
--- a/apps/sim/app/api/tools/wordpress/upload/route.ts
+++ b/apps/sim/app/api/tools/wordpress/upload/route.ts
@@ -11,7 +11,8 @@ import {
getMimeTypeFromExtension,
processSingleFileToUserFile,
} from '@/lib/uploads/utils/file-utils'
-import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
@@ -89,10 +90,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
let fileBuffer: Buffer
+ let resolvedContentType: string
try {
- fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ const servable = await downloadServableFileFromStorage(userFile, requestId, logger)
+ fileBuffer = servable.buffer
+ resolvedContentType = servable.contentType
} catch (error) {
+ const notReady = docNotReadyResponse(error)
+ if (notReady) return notReady
logger.error(`[${requestId}] Failed to download file:`, error)
return NextResponse.json(
{
@@ -104,7 +110,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const filename = validatedData.filename || userFile.name
- const mimeType = userFile.type || getMimeTypeFromExtension(getFileExtension(filename))
+ const mimeType =
+ resolvedContentType || userFile.type || getMimeTypeFromExtension(getFileExtension(filename))
logger.info(`[${requestId}] Uploading to WordPress`, {
siteId: validatedData.siteId,
diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts
index 4ac098c3a2d..251c556ec79 100644
--- a/apps/sim/app/api/webhooks/outbox/process/route.ts
+++ b/apps/sim/app/api/webhooks/outbox/process/route.ts
@@ -1,3 +1,4 @@
+import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
@@ -7,6 +8,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
+import { reapStaleBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
const logger = createLogger('OutboxProcessorAPI')
@@ -33,12 +35,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
minRemainingMs: 95_000,
})
- logger.info('Outbox processing completed', { requestId, ...result })
+ // Reap fork background-work rows stuck `processing` past their TTL (worker crash /
+ // restart has no in-task hook). Independent of the outbox; a failure here must not
+ // fail the outbox run, so it's guarded separately.
+ let reapedBackgroundWork = 0
+ try {
+ reapedBackgroundWork = await reapStaleBackgroundWork(db)
+ } catch (error) {
+ logger.error('Background-work reap failed', { requestId, error: toError(error).message })
+ }
+
+ logger.info('Outbox processing completed', { requestId, ...result, reapedBackgroundWork })
return NextResponse.json({
success: true,
requestId,
result,
+ reapedBackgroundWork,
})
} catch (error) {
logger.error('Outbox processing failed', { requestId, error: toError(error).message })
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 13ee516f661..246a62ab694 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -454,7 +454,7 @@ async function handleExecutePost(
}
// Programmatic execution (API key or public API) is gated on the workflow's
- // workspace billed account — the same entity MCP/A2A/webhooks/chat gate on —
+ // workspace billed account — the same entity MCP/webhooks/chat gate on —
// so a paid workspace is never blocked because an individual is on free.
if (auth.authType === AuthType.API_KEY || isPublicApiAccess) {
if (!gateWorkspaceId) {
diff --git a/apps/sim/app/api/workspaces/[id]/background-work/route.ts b/apps/sim/app/api/workspaces/[id]/background-work/route.ts
new file mode 100644
index 00000000000..066ba5c4a8b
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/background-work/route.ts
@@ -0,0 +1,45 @@
+import { db } from '@sim/db'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getWorkspaceBackgroundWorkContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
+import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
+
+export const GET = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+
+ const access = await checkWorkspaceAccess(id, session.user.id)
+ if (!access.exists) {
+ return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
+ }
+ if (!access.canAdmin) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const rows = await listSurfacedBackgroundWork(db, id)
+ return NextResponse.json({
+ items: rows.map((row) => ({
+ id: row.id,
+ workspaceId: row.workspaceId,
+ workflowId: row.workflowId,
+ kind: row.kind,
+ status: row.status,
+ message: row.message,
+ error: row.error,
+ metadata: row.metadata ?? null,
+ startedAt: row.startedAt.toISOString(),
+ completedAt: row.completedAt ? row.completedAt.toISOString() : null,
+ })),
+ })
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
new file mode 100644
index 00000000000..4631e16fda2
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
@@ -0,0 +1,160 @@
+import { db } from '@sim/db'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { loadTargetDraftSubBlocks } from '@/lib/workspaces/fork/copy/copy-workflows'
+import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
+import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
+import { loadForkBlockMap } from '@/lib/workspaces/fork/mapping/block-map-store'
+import {
+ collectForkDependentReconfigs,
+ collectForkResourceUsages,
+} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
+import {
+ forkDependentValueKey,
+ loadForkDependentValues,
+} from '@/lib/workspaces/fork/mapping/dependent-value-store'
+import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
+import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
+import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
+
+export const GET = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getForkDiffContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+ const { otherWorkspaceId, direction } = parsed.data.query
+
+ const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
+
+ const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(
+ auth.sourceWorkspaceId
+ )
+ const plan = await computeForkPromotePlan({
+ executor: db,
+ edge: auth.edge,
+ sourceWorkspaceId: auth.sourceWorkspaceId,
+ targetWorkspaceId: auth.targetWorkspaceId,
+ direction,
+ deployedSourceWorkflows: deployedWorkflows,
+ sourceStates,
+ })
+
+ // Resolve dependent-reconfig target block ids through the SAME persisted block map the
+ // sync will use, so a re-pick the modal keys by target block id lands on the block the
+ // promote actually writes (on push that's the parent's original id, not a derived one).
+ const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId
+ const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId)
+ const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
+
+ // Stored dependent values are the source of truth for what each selector is set to. Overlay
+ // them as each field's currentValue so the modal pre-fills what the user actually saved. For
+ // an edge that predates the store the fallback is the TARGET's own configured value (loaded
+ // from its draft) - never the source's, which would overwrite the target's selection on the
+ // first sync. Both the stored read and the draft read are scoped to the plan's replace
+ // targets, the only workflows with dependents to reconfigure.
+ const replaceTargetIds = plan.items
+ .filter((item) => item.mode === 'replace')
+ .map((item) => item.targetWorkflowId)
+ const [storedValues, targetDraftByWorkflow] = await Promise.all([
+ loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
+ loadTargetDraftSubBlocks(db, replaceTargetIds),
+ ])
+ const storedByKey = new Map(
+ storedValues.map((entry) => [
+ forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
+ entry.value,
+ ])
+ )
+
+ // Source block subBlocks keyed by their resolved target identity, so the first-sync draft
+ // fallback can identity-check a nested tool against the SOURCE dependent tool it came from -
+ // an index alone may point at a different tool in the target draft, whose value isn't the
+ // dependent's. Read structurally (only each subblock's `value`), so the in-memory state's
+ // blocks pass without a cast.
+ const sourceBlocksByTarget = new Map>>()
+ for (const item of plan.items) {
+ if (item.mode !== 'replace') continue
+ const state = sourceStates.get(item.sourceWorkflowId)
+ if (!state) continue
+ const byBlock = new Map>()
+ for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
+ byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {})
+ }
+ sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
+ }
+
+ const dependentReconfigs = collectForkDependentReconfigs(
+ plan.items,
+ sourceStates,
+ resolveBlockId
+ ).map((field) => ({
+ ...field,
+ currentValue:
+ storedByKey.get(
+ forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
+ ) ??
+ readTargetDraftDependentValue(
+ targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
+ sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
+ field.subBlockKey
+ ),
+ }))
+
+ const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
+ kind: reference.kind,
+ sourceId: reference.sourceId,
+ required: reference.required,
+ blockName: reference.blockName,
+ })
+
+ // Orient the mapping around the workspace the modal is open in (`id`): show the
+ // caller's workflow name first, the sync partner's second, so renames are legible.
+ const currentIsSource = auth.sourceWorkspaceId === id
+ const workflows = [
+ ...plan.items.map((item) => {
+ if (item.mode === 'create') {
+ // The target inherits the source's name, so both sides read the same.
+ return {
+ action: 'create' as const,
+ currentName: item.sourceMeta.name,
+ otherName: item.sourceMeta.name,
+ }
+ }
+ const targetName = item.targetName ?? item.sourceMeta.name
+ return {
+ action: 'update' as const,
+ currentName: currentIsSource ? item.sourceMeta.name : targetName,
+ otherName: currentIsSource ? targetName : item.sourceMeta.name,
+ }
+ }),
+ ...plan.archivedTargets.map((target) => ({
+ action: 'archive' as const,
+ currentName: target.name,
+ otherName: target.name,
+ })),
+ ]
+
+ return NextResponse.json({
+ sourceWorkspaceId: auth.sourceWorkspaceId,
+ targetWorkspaceId: auth.targetWorkspaceId,
+ willUpdate: plan.willUpdate,
+ willCreate: plan.willCreate,
+ willArchive: plan.willArchive,
+ workflows,
+ unmappedRequired: plan.unmappedRequired.map(toRef),
+ unmappedOptional: plan.unmappedOptional.map(toRef),
+ mcpReauthServerIds: plan.mcpReauthServerIds,
+ inlineSecretSources: plan.inlineSecretSources,
+ dependentReconfigs,
+ resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
+ })
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts
new file mode 100644
index 00000000000..5e409b78acd
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts
@@ -0,0 +1,56 @@
+import { db } from '@sim/db'
+import { workspace } from '@sim/db/schema'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
+import { getForkParent } from '@/lib/workspaces/fork/lineage/lineage'
+import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store'
+
+export const GET = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getForkLineageContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id: workspaceId } = parsed.data.params
+
+ await assertWorkspaceAdminAccess(workspaceId, session.user.id)
+
+ const [parent, run] = await Promise.all([
+ getForkParent(workspaceId),
+ getUndoableRunForTarget(db, workspaceId),
+ ])
+
+ let undoableRun: {
+ otherWorkspaceId: string
+ otherName: string
+ direction: 'push' | 'pull'
+ } | null = null
+ if (run) {
+ const [other] = await db
+ .select({ name: workspace.name })
+ .from(workspace)
+ .where(eq(workspace.id, run.sourceWorkspaceId))
+ .limit(1)
+ undoableRun = {
+ otherWorkspaceId: run.sourceWorkspaceId,
+ otherName: other?.name ?? 'workspace',
+ direction: run.direction,
+ }
+ }
+
+ return NextResponse.json({
+ workspaceId,
+ parent,
+ undoableRun,
+ })
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts
new file mode 100644
index 00000000000..0729b0d4ff6
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts
@@ -0,0 +1,75 @@
+import { db } from '@sim/db'
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ getForkMappingContract,
+ updateForkMappingContract,
+} from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
+import { acquireForkEdgeLock, setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
+import {
+ applyForkMappingEntries,
+ getForkMappingView,
+ validateForkMappingTargets,
+} from '@/lib/workspaces/fork/mapping/mapping-service'
+
+export const GET = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getForkMappingContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+ const { otherWorkspaceId, direction } = parsed.data.query
+
+ const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
+
+ const { entries } = await getForkMappingView({
+ edge: auth.edge,
+ sourceWorkspaceId: auth.sourceWorkspaceId,
+ targetWorkspaceId: auth.targetWorkspaceId,
+ })
+
+ return NextResponse.json({
+ childWorkspaceId: auth.edge.childWorkspaceId,
+ parentWorkspaceId: auth.edge.parentWorkspaceId,
+ sourceWorkspaceId: auth.sourceWorkspaceId,
+ targetWorkspaceId: auth.targetWorkspaceId,
+ entries,
+ })
+ }
+)
+
+export const PUT = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(updateForkMappingContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+ const { otherWorkspaceId, direction, entries } = parsed.data.body
+
+ const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
+
+ await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries)
+
+ // Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted
+ // then re-upserted parent-side) can't leave duplicate rows for the same source. Same
+ // edge lock promote/rollback use, with a bounded wait.
+ const updated = await db.transaction(async (tx) => {
+ await setForkLockTimeout(tx)
+ await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
+ return applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries)
+ })
+
+ return NextResponse.json({ success: true as const, updated })
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
new file mode 100644
index 00000000000..eba0e7b1c8f
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
@@ -0,0 +1,118 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { db } from '@sim/db'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { promoteForkContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
+import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
+import { promoteFork } from '@/lib/workspaces/fork/promote/promote'
+
+const logger = createLogger('WorkspaceForkPromoteAPI')
+
+export const POST = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateRequestId()
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(promoteForkContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+ const { otherWorkspaceId, direction, dependentValues } = parsed.data.body
+
+ const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
+
+ const result = await promoteFork({
+ edge: auth.edge,
+ sourceWorkspaceId: auth.sourceWorkspaceId,
+ targetWorkspaceId: auth.targetWorkspaceId,
+ direction,
+ userId: session.user.id,
+ dependentValues,
+ requestId,
+ })
+
+ const body = {
+ promoteRunId: result.promoteRunId,
+ updated: result.updated,
+ created: result.created,
+ archived: result.archived,
+ redeployed: result.redeployed,
+ deployFailed: result.deployFailed,
+ unmappedRequired: result.unmappedRequired,
+ needsConfiguration: result.needsConfiguration,
+ clearedOptional: result.clearedOptional,
+ }
+
+ if (result.blocked) {
+ logger.info(`[${requestId}] Promote blocked (${result.blocked})`, {
+ sourceWorkspaceId: auth.sourceWorkspaceId,
+ targetWorkspaceId: auth.targetWorkspaceId,
+ })
+ return NextResponse.json(body)
+ }
+
+ recordAudit({
+ workspaceId: auth.targetWorkspaceId,
+ actorId: session.user.id,
+ action: AuditAction.WORKSPACE_FORK_PROMOTED,
+ resourceType: AuditResourceType.WORKSPACE,
+ resourceId: auth.targetWorkspaceId,
+ actorName: session.user.name ?? undefined,
+ actorEmail: session.user.email ?? undefined,
+ resourceName: auth.target.name,
+ description: `Promoted workflows from "${auth.source.name}" to "${auth.target.name}"`,
+ metadata: {
+ direction,
+ sourceWorkspaceId: auth.sourceWorkspaceId,
+ updated: result.updated,
+ created: result.created,
+ archived: result.archived,
+ redeployed: result.redeployed,
+ },
+ request: req,
+ })
+
+ const otherName =
+ otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name
+ await recordBackgroundWork(db, {
+ workspaceId: id,
+ kind: 'fork_sync',
+ status:
+ result.deployFailed > 0 ||
+ result.needsConfiguration.length > 0 ||
+ result.clearedOptional.length > 0
+ ? 'completed_with_warnings'
+ : 'completed',
+ message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
+ metadata: {
+ actorName: session.user.name ?? undefined,
+ otherWorkspaceName: otherName,
+ direction,
+ updated: result.updated,
+ created: result.created,
+ archived: result.archived,
+ redeployed: result.redeployed,
+ deployFailed: result.deployFailed,
+ updatedNames: result.updatedNames,
+ createdNames: result.createdNames,
+ archivedNames: result.archivedNames,
+ needsConfiguration: result.needsConfiguration,
+ clearedOptional: result.clearedOptional,
+ },
+ }).catch((error) =>
+ logger.error(`[${requestId}] Failed to record sync activity`, {
+ error: getErrorMessage(error),
+ })
+ )
+
+ return NextResponse.json(body)
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts
new file mode 100644
index 00000000000..ef489fc6a18
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts
@@ -0,0 +1,26 @@
+import { db } from '@sim/db'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
+import { listForkCopyableResources } from '@/lib/workspaces/fork/mapping/resources'
+
+export const GET = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getForkResourcesContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+
+ await assertWorkspaceAdminAccess(id, session.user.id)
+
+ const resources = await listForkCopyableResources(db, id)
+ return NextResponse.json(resources)
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts
new file mode 100644
index 00000000000..dffb841af5e
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts
@@ -0,0 +1,84 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { db } from '@sim/db'
+import { workspace } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { rollbackForkContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
+import { assertCanRollback } from '@/lib/workspaces/fork/lineage/authz'
+import { rollbackFork } from '@/lib/workspaces/fork/promote/rollback'
+
+const logger = createLogger('WorkspaceForkRollbackAPI')
+
+export const POST = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateRequestId()
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(rollbackForkContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+ const { otherWorkspaceId } = parsed.data.body
+
+ const target = await assertCanRollback(id, session.user.id)
+
+ const result = await rollbackFork({
+ targetWorkspaceId: id,
+ otherWorkspaceId,
+ userId: session.user.id,
+ requestId,
+ })
+
+ recordAudit({
+ workspaceId: id,
+ actorId: session.user.id,
+ action: AuditAction.WORKSPACE_FORK_ROLLED_BACK,
+ resourceType: AuditResourceType.WORKSPACE,
+ resourceId: id,
+ actorName: session.user.name ?? undefined,
+ actorEmail: session.user.email ?? undefined,
+ resourceName: target.name,
+ description: `Rolled back the last promote into "${target.name}"`,
+ metadata: { otherWorkspaceId, ...result },
+ request: req,
+ })
+
+ // Durable audit entry scoped to this workspace so the undo shows in its Manage Forks
+ // → Activity log. Non-critical: a failure must not fail the (committed) rollback.
+ const [other] = await db
+ .select({ name: workspace.name })
+ .from(workspace)
+ .where(eq(workspace.id, otherWorkspaceId))
+ .limit(1)
+ const otherName = other?.name ?? 'the source workspace'
+ await recordBackgroundWork(db, {
+ workspaceId: id,
+ kind: 'fork_rollback',
+ status: result.skipped > 0 ? 'completed_with_warnings' : 'completed',
+ message: `Undid the last sync from "${otherName}"`,
+ metadata: {
+ actorName: session.user.name ?? undefined,
+ otherWorkspaceName: otherName,
+ restored: result.restored,
+ removed: result.archived,
+ unarchived: result.unarchived,
+ skipped: result.skipped,
+ },
+ }).catch((error) =>
+ logger.error(`[${requestId}] Failed to record rollback activity`, {
+ error: getErrorMessage(error),
+ })
+ )
+
+ return NextResponse.json(result)
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.ts b/apps/sim/app/api/workspaces/[id]/fork/route.ts
new file mode 100644
index 00000000000..27bea2f1b99
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/route.ts
@@ -0,0 +1,67 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { forkWorkspaceContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { createFork } from '@/lib/workspaces/fork/create-fork'
+import { assertCanFork } from '@/lib/workspaces/fork/lineage/authz'
+
+const logger = createLogger('WorkspaceForkAPI')
+
+export const POST = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const { id: sourceWorkspaceId } = await context.params
+ const requestId = generateRequestId()
+
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { source, policy } = await assertCanFork(sourceWorkspaceId, session.user.id)
+
+ const parsed = await parseRequest(forkWorkspaceContract, req, context)
+ if (!parsed.success) return parsed.response
+
+ const copy = parsed.data.body.copy
+ const result = await createFork({
+ source,
+ policy,
+ userId: session.user.id,
+ actorName: session.user.name ?? undefined,
+ name: parsed.data.body.name,
+ selection: {
+ files: copy?.files ?? [],
+ tables: copy?.tables ?? [],
+ knowledgeBases: copy?.knowledgeBases ?? [],
+ customTools: copy?.customTools ?? [],
+ skills: copy?.skills ?? [],
+ mcpServers: copy?.mcpServers ?? [],
+ },
+ requestId,
+ })
+
+ recordAudit({
+ workspaceId: result.workspace.id,
+ actorId: session.user.id,
+ action: AuditAction.WORKSPACE_FORKED,
+ resourceType: AuditResourceType.WORKSPACE,
+ resourceId: result.workspace.id,
+ actorName: session.user.name ?? undefined,
+ actorEmail: session.user.email ?? undefined,
+ resourceName: result.workspace.name,
+ description: `Forked workspace from "${source.name}"`,
+ metadata: {
+ parentWorkspaceId: source.id,
+ workflowsCopied: result.workflowsCopied,
+ },
+ request: req,
+ })
+
+ logger.info(`[${requestId}] Forked workspace ${sourceWorkspaceId} -> ${result.workspace.id}`)
+ return NextResponse.json(result, { status: 201 })
+ }
+)
diff --git a/apps/sim/app/changelog/components/timeline-list.tsx b/apps/sim/app/changelog/components/timeline-list.tsx
index 703731c1e17..abcb1945ab7 100644
--- a/apps/sim/app/changelog/components/timeline-list.tsx
+++ b/apps/sim/app/changelog/components/timeline-list.tsx
@@ -3,7 +3,7 @@
import React from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
+import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn'
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
type Props = { initialEntries: ChangelogEntry[] }
diff --git a/apps/sim/app/changelog/loading.tsx b/apps/sim/app/changelog/loading.tsx
index 695c11a3a42..47334b9f3b0 100644
--- a/apps/sim/app/changelog/loading.tsx
+++ b/apps/sim/app/changelog/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function ChangelogLoading() {
return (
diff --git a/apps/sim/app/chat/[identifier]/loading.tsx b/apps/sim/app/chat/[identifier]/loading.tsx
index 921e5a801cc..9b032730a1c 100644
--- a/apps/sim/app/chat/[identifier]/loading.tsx
+++ b/apps/sim/app/chat/[identifier]/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function ChatLoading() {
return (
diff --git a/apps/sim/app/chat/components/auth/email/email-auth.tsx b/apps/sim/app/chat/components/auth/email/email-auth.tsx
index 3224e0bb4bb..0ad33ab3d4f 100644
--- a/apps/sim/app/chat/components/auth/email/email-auth.tsx
+++ b/apps/sim/app/chat/components/auth/email/email-auth.tsx
@@ -1,10 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
+import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
diff --git a/apps/sim/app/chat/components/auth/password/password-auth.tsx b/apps/sim/app/chat/components/auth/password/password-auth.tsx
index d8eca769951..563faa7c308 100644
--- a/apps/sim/app/chat/components/auth/password/password-auth.tsx
+++ b/apps/sim/app/chat/components/auth/password/password-auth.tsx
@@ -1,11 +1,10 @@
'use client'
import { useState } from 'react'
+import { cn, Input, Label, Loader } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { Eye, EyeOff } from 'lucide-react'
-import { Input, Label, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
diff --git a/apps/sim/app/chat/components/input/input.tsx b/apps/sim/app/chat/components/input/input.tsx
index c31342c1e64..72aa99fb9d0 100644
--- a/apps/sim/app/chat/components/input/input.tsx
+++ b/apps/sim/app/chat/components/input/input.tsx
@@ -2,12 +2,10 @@
import type React from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
+import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { ArrowUp, Mic, Paperclip, X } from 'lucide-react'
-import { Badge, Tooltip } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { VoiceInput } from '@/app/chat/components/input/voice-input'
diff --git a/apps/sim/app/chat/components/loading-state/loading-state.tsx b/apps/sim/app/chat/components/loading-state/loading-state.tsx
index 146aa8680c4..6d8a73e4601 100644
--- a/apps/sim/app/chat/components/loading-state/loading-state.tsx
+++ b/apps/sim/app/chat/components/loading-state/loading-state.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export function ChatLoadingState() {
return (
diff --git a/apps/sim/app/chat/components/message/components/file-download.test.tsx b/apps/sim/app/chat/components/message/components/file-download.test.tsx
index a707bbd55b5..423cdc78731 100644
--- a/apps/sim/app/chat/components/message/components/file-download.test.tsx
+++ b/apps/sim/app/chat/components/message/components/file-download.test.tsx
@@ -3,7 +3,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
-vi.mock('@/components/emcn', () => ({
+vi.mock('@sim/emcn', () => ({
Button: () => null,
Download: () => null,
Loader: () => null,
diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx
index 4ba289b1ec1..8dfb200b61b 100644
--- a/apps/sim/app/chat/components/message/components/file-download.tsx
+++ b/apps/sim/app/chat/components/message/components/file-download.tsx
@@ -1,10 +1,10 @@
'use client'
import { useState } from 'react'
+import { Button, Download, Loader } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { Music } from 'lucide-react'
-import { Button, Download, Loader } from '@/components/emcn'
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
import { getBrowserOrigin } from '@/lib/core/utils/urls'
import type { ChatFile } from '@/app/chat/components/message/message'
diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
index 84b60eea794..00cfcd31ee2 100644
--- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
+++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
@@ -1,7 +1,7 @@
import React, { type HTMLAttributes, memo, type ReactNode } from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
-import { CopyCodeButton, Tooltip } from '@/components/emcn'
+import { CopyCodeButton, Tooltip } from '@sim/emcn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
diff --git a/apps/sim/app/chat/components/message/message.test.tsx b/apps/sim/app/chat/components/message/message.test.tsx
index 50f0323b3f7..a6428d5188c 100644
--- a/apps/sim/app/chat/components/message/message.test.tsx
+++ b/apps/sim/app/chat/components/message/message.test.tsx
@@ -3,7 +3,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
-vi.mock('@/components/emcn', () => ({
+vi.mock('@sim/emcn', () => ({
Duplicate: () => null,
Tooltip: {},
}))
diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx
index 661dd4e1657..12181e0f300 100644
--- a/apps/sim/app/chat/components/message/message.tsx
+++ b/apps/sim/app/chat/components/message/message.tsx
@@ -1,8 +1,8 @@
'use client'
import { memo, useState } from 'react'
+import { Duplicate, Tooltip } from '@sim/emcn'
import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
-import { Duplicate, Tooltip } from '@/components/emcn'
import {
ChatFileDownload,
ChatFileDownloadAll,
diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx
index f313b24046e..7a7f8ec70f1 100644
--- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx
+++ b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx
@@ -1,13 +1,13 @@
'use client'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
+import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { Mic, MicOff, Phone } from 'lucide-react'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { requestJson } from '@/lib/api/client/request'
import { speechTokenContract } from '@/lib/api/contracts/media/speech'
-import { cn } from '@/lib/core/utils/cn'
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
import {
CHUNK_SEND_INTERVAL_MS,
diff --git a/apps/sim/app/credential-account/[token]/loading.tsx b/apps/sim/app/credential-account/[token]/loading.tsx
index f4574e877a9..275aa3b854d 100644
--- a/apps/sim/app/credential-account/[token]/loading.tsx
+++ b/apps/sim/app/credential-account/[token]/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function CredentialAccountLoading() {
return (
diff --git a/apps/sim/app/f/[token]/public-file-auth.tsx b/apps/sim/app/f/[token]/public-file-auth.tsx
index fa7d57dd05a..cc665e3758f 100644
--- a/apps/sim/app/f/[token]/public-file-auth.tsx
+++ b/apps/sim/app/f/[token]/public-file-auth.tsx
@@ -1,11 +1,10 @@
'use client'
import { useState } from 'react'
+import { cn, Input, Label, Loader } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { Eye, EyeOff } from 'lucide-react'
import { useRouter } from 'next/navigation'
-import { Input, Label, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
import { usePublicFileAuth } from '@/hooks/queries/public-shares'
diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx
index b05b487574c..f6b01a6b7f5 100644
--- a/apps/sim/app/f/[token]/public-file-email-auth.tsx
+++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx
@@ -1,10 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
+import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
-import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
diff --git a/apps/sim/app/f/[token]/public-file-sso-auth.tsx b/apps/sim/app/f/[token]/public-file-sso-auth.tsx
index 247975a4b29..a5f09fc9170 100644
--- a/apps/sim/app/f/[token]/public-file-sso-auth.tsx
+++ b/apps/sim/app/f/[token]/public-file-sso-auth.tsx
@@ -1,12 +1,11 @@
'use client'
import { useState } from 'react'
+import { cn, Input, Label, Loader } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
-import { Input, Label, Loader } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
-import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx
index 360119e4945..3f61b66e3f9 100644
--- a/apps/sim/app/f/[token]/public-file-view.tsx
+++ b/apps/sim/app/f/[token]/public-file-view.tsx
@@ -1,10 +1,10 @@
'use client'
import { useMemo } from 'react'
+import { Chip } from '@sim/emcn'
+import { Download } from '@sim/emcn/icons'
import Image from 'next/image'
import Link from 'next/link'
-import { Chip } from '@/components/emcn'
-import { Download } from '@/components/emcn/icons'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { buildProvenance } from '@/app/f/[token]/utils'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
diff --git a/apps/sim/app/invite/[id]/loading.tsx b/apps/sim/app/invite/[id]/loading.tsx
index b72ba19f4f5..b26abbbaf59 100644
--- a/apps/sim/app/invite/[id]/loading.tsx
+++ b/apps/sim/app/invite/[id]/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function InviteLoading() {
return (
diff --git a/apps/sim/app/invite/components/status-card.tsx b/apps/sim/app/invite/components/status-card.tsx
index 920efb43abb..b067c0716e6 100644
--- a/apps/sim/app/invite/components/status-card.tsx
+++ b/apps/sim/app/invite/components/status-card.tsx
@@ -1,8 +1,6 @@
'use client'
-
+import { cn, Loader } from '@sim/emcn'
import { useRouter } from 'next/navigation'
-import { Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
interface InviteStatusCardProps {
diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx
index 9a44722b250..9acd0f74367 100644
--- a/apps/sim/app/playground/page.tsx
+++ b/apps/sim/app/playground/page.tsx
@@ -1,8 +1,6 @@
'use client'
import { useState, useSyncExternalStore } from 'react'
-import { ArrowLeft, Folder, Moon, Sun } from 'lucide-react'
-import { notFound, useRouter } from 'next/navigation'
import {
Avatar,
AvatarFallback,
@@ -86,7 +84,9 @@ import {
Wrap,
ZoomIn,
ZoomOut,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { ArrowLeft, Folder, Moon, Sun } from 'lucide-react'
+import { notFound, useRouter } from 'next/navigation'
import { env, isTruthy } from '@/lib/core/config/env'
function Section({ title, children }: { title: string; children: React.ReactNode }) {
diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx
index 3c7197a7e96..81a63358934 100644
--- a/apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx
+++ b/apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function ResumeLoading() {
return (
diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx
index a802d6ebe3b..b8aa82792f8 100644
--- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx
+++ b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx
@@ -1,9 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { useQueryClient } from '@tanstack/react-query'
-import { RefreshCw } from 'lucide-react'
-import { useRouter } from 'next/navigation'
import {
Badge,
Button,
@@ -18,7 +15,10 @@ import {
TableRow,
Textarea,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { useQueryClient } from '@tanstack/react-query'
+import { RefreshCw } from 'lucide-react'
+import { useRouter } from 'next/navigation'
import {
Select,
SelectContent,
diff --git a/apps/sim/app/unsubscribe/loading.tsx b/apps/sim/app/unsubscribe/loading.tsx
index 6ae94a8b934..5f625c75bbd 100644
--- a/apps/sim/app/unsubscribe/loading.tsx
+++ b/apps/sim/app/unsubscribe/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
export default function UnsubscribeLoading() {
return (
diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx
index c7fac2f9aa4..1ace40a9b74 100644
--- a/apps/sim/app/unsubscribe/unsubscribe.tsx
+++ b/apps/sim/app/unsubscribe/unsubscribe.tsx
@@ -1,9 +1,9 @@
'use client'
import { Suspense } from 'react'
+import { Loader } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useSearchParams } from 'next/navigation'
-import { Loader } from '@/components/emcn'
import type { UnsubscribeType } from '@/lib/api/contracts/user'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { InviteLayout } from '@/app/invite/components'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index 3ab1ee6ec03..fa55168db32 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { type ComponentType, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import {
Badge,
ChipModal,
@@ -14,7 +12,9 @@ import {
InfoCard,
InfoCardItem,
InfoCardList,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx
index 57dfeb989af..0cc2baa0d99 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
interface ConversationListItemProps {
title: string
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx
index 305489df046..f66a45b2335 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import {
ChipModal,
ChipModalBody,
@@ -10,7 +8,9 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useUpsertWorkspaceCredentialMember,
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
index 7afd46637ae..d416af43b67 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
@@ -1,17 +1,4 @@
-/**
- * Shared chip-field chrome for the credential and secret detail surfaces.
- *
- * These mirror {@link ChipInput} exactly (30px tall,
- * `rounded-lg`, `border-1`, `surface-5`/`surface-4`, normal-weight body text,
- * and no focus ring) but as a wrapper + inner-input pair, so a field can host a
- * borderless input alongside a trailing slot (a copy button, a reveal toggle).
- * Using one definition keeps every chip field — list rows,
- * copyable IDs, secret values, display-name/description editors — pixel-identical
- * to the canonical chip input instead of each re-deriving the tokens.
- */
-
-import { chipFieldSurfaceClass, chipFieldTextClass } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+import { chipFieldSurfaceClass, chipFieldTextClass, cn } from '@sim/emcn'
/** Pill wrapper. Override height/alignment (e.g. a textarea) via `cn`. */
export const CHIP_FIELD_SHELL = cn('flex h-[30px] items-center gap-1.5 px-2', chipFieldSurfaceClass)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx
index a9067b55f1d..2d8a0e9c548 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx
@@ -1,9 +1,7 @@
'use client'
-
+import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { Avatar, AvatarFallback, Chip, ChipDropdown } from '@/components/emcn'
import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions'
-import { cn } from '@/lib/core/utils/cn'
import { getUserColor } from '@/lib/workspaces/colors'
import {
useRemoveWorkspaceCredentialMember,
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx
index e13c3ce7685..e9f9ae68528 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx
@@ -1,4 +1,4 @@
-import { ChipConfirmModal } from '@/components/emcn'
+import { ChipConfirmModal } from '@sim/emcn'
interface UnsavedChangesModalProps {
open: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
index f945d9c18ea..7bd0e1375d0 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
@@ -1,9 +1,9 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
+import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { toast } from '@/components/emcn'
import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials'
import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
index f6279b6f9f3..461ab2e9473 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
@@ -1,9 +1,9 @@
'use client'
import { type ReactNode, useEffect } from 'react'
+import { Button } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { TriangleAlert } from 'lucide-react'
-import { Button } from '@/components/emcn'
/** Props shape required by Next.js error boundary files (`error.tsx`). */
export interface ErrorBoundaryProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
index a4a23572e25..8a995752b33 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { Banner } from '@/components/emcn'
+import { Banner } from '@sim/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useStopImpersonating } from '@/hooks/queries/admin-users'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index 74cf5990e2a..91ebc37ea42 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -1,8 +1,6 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
-import { GitBranch } from 'lucide-react'
-import { useParams, useRouter } from 'next/navigation'
import {
Check,
ChipModal,
@@ -10,13 +8,15 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ cn,
Duplicate,
ThumbsDown,
ThumbsUp,
Tooltip,
toast,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { GitBranch } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx
index c05757d2fb2..dc7a9dcaf35 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx
@@ -2,13 +2,7 @@
import type React from 'react'
import { memo } from 'react'
-import {
- FloatingTooltip,
- isTextClipped,
- useFloatingTooltip,
- useIsOverflowing,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+import { cn, FloatingTooltip, isTextClipped, useFloatingTooltip, useIsOverflowing } from '@sim/emcn'
interface FloatingOverflowTextProps {
/** Full text shown in the tooltip and used as the default visible content. */
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
index f03a8cdcdf2..f6b464404cd 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
@@ -8,14 +8,13 @@ import {
useRef,
useState,
} from 'react'
-import { ArrowUpLeft } from 'lucide-react'
-import { createPortal } from 'react-dom'
import {
Chip,
ChipChevronDown,
chipContentIconClass,
chipGeometryClass,
chipVariants,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -29,8 +28,9 @@ import {
PopoverSection,
useFloatingTooltip,
useIsOverflowing,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { ArrowUpLeft } from 'lucide-react'
+import { createPortal } from 'react-dom'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
index 0926fb31ba8..af98a4ccfbf 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
@@ -5,6 +5,7 @@ import {
ArrowUp,
ArrowUpDown,
Chip,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -14,8 +15,7 @@ import {
POPOVER_ANIMATION_CLASSES,
Search,
X,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
const SEARCH_ICON = (
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
index e8a313dc997..ed960210aa6 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
@@ -12,17 +12,17 @@ import {
useRef,
useState,
} from 'react'
-import { useVirtualizer } from '@tanstack/react-virtual'
-import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
Button,
Checkbox,
cellIconNodeClass,
chipContentGap,
chipContentLabelClass,
+ cn,
Loader,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { useVirtualizer } from '@tanstack/react-virtual'
+import { ChevronLeft, ChevronRight } from 'lucide-react'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
index f463da30949..3f6a9fa9fe5 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
@@ -1,8 +1,8 @@
'use client'
import { useEffect, useLayoutEffect } from 'react'
+import { cn } from '@sim/emcn'
import { usePathname } from 'next/navigation'
-import { cn } from '@/lib/core/utils/cn'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
import { useSidebarStore } from '@/stores/sidebar/store'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx
index 9a2ae9b93d1..f818286ff26 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx
@@ -1,8 +1,7 @@
'use client'
-
-import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import {
Button,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -11,9 +10,9 @@ import {
Folder,
Tooltip,
Trash,
-} from '@/components/emcn'
-import { Download } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { Download } from '@sim/emcn/icons'
+import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
index edc10b62f5e..8e02ebfcd69 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
-import { ChipConfirmModal } from '@/components/emcn'
+import { ChipConfirmModal } from '@sim/emcn'
interface DeleteConfirmModalProps {
open: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx
index 83b4defd0c6..f7639dde029 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx
@@ -14,8 +14,8 @@ import {
Folder,
FolderInput,
Pencil,
-} from '@/components/emcn'
-import { Download, Link, Trash } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Download, Link, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
index eae5e438133..b91d1b99318 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
@@ -1,9 +1,9 @@
'use client'
import { useCallback, useEffect, useRef } from 'react'
+import { toast } from '@sim/emcn'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
-import { toast } from '@/components/emcn'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useImportFileAsTable } from '@/hooks/queries/tables'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx
index 672811de475..12a3d72a794 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx
@@ -1,7 +1,7 @@
'use client'
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
interface EditConfig {
onCellChange: (row: number, col: number, value: string) => void
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
index 4d0d4b8583d..6f1e8f75df0 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
@@ -1,9 +1,9 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
+import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
import { PreviewToolbar } from './preview-toolbar'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
index 27ed6d8464c..904d3c06a11 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
@@ -1,6 +1,5 @@
'use client'
-import { Scissors } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -8,8 +7,9 @@ import {
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Clipboard, Duplicate, Search, SelectAll } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Clipboard, Duplicate, Search, SelectAll } from '@sim/emcn/icons'
+import { Scissors } from 'lucide-react'
interface EditorContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
index c9971ad7be0..764349c42ad 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
@@ -1,7 +1,7 @@
'use client'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
-import '@/components/emcn/components/code/code.css'
+import '@sim/emcn/components/code/code.css'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
index f72d71a9a81..4796ae6bb1e 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
@@ -1,8 +1,8 @@
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
+import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { cn } from '@/lib/core/utils/cn'
const logger = createLogger('FilePreview')
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx
index 005218e7aff..0d8a326a881 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx
@@ -1,6 +1,5 @@
+import { Chip, cn } from '@sim/emcn'
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
-import { Chip } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
interface PreviewNavigationControls {
current: number
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx
index 16e38ea987e..c17577c917b 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx
@@ -1,18 +1,18 @@
import { useEffect, useState } from 'react'
-import type { JSONContent } from '@tiptap/core'
-import { CodeBlock } from '@tiptap/extension-code-block'
-import type { ReactNodeViewProps } from '@tiptap/react'
-import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
-import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
import {
chipVariants,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+ useCopyToClipboard,
+} from '@sim/emcn'
+import type { JSONContent } from '@tiptap/core'
+import { CodeBlock } from '@tiptap/extension-code-block'
+import type { ReactNodeViewProps } from '@tiptap/react'
+import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
+import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
import { detectLanguage } from './detect-language'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx
index 187d4c81f4a..0e4f1dbb69b 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react'
+import { cn } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { Image } from '@tiptap/extension-image'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
-import { cn } from '@/lib/core/utils/cn'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { normalizeLinkHref } from './markdown-fidelity'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx
index 3ebff0132a3..d64d5e4958e 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx
@@ -1,8 +1,8 @@
import type { MouseEvent } from 'react'
+import { cn } from '@sim/emcn'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useParams, useRouter } from 'next/navigation'
-import { cn } from '@/lib/core/utils/cn'
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
import { mentionIcon } from './mention-icon'
import { MarkdownMention, type MentionAttrs } from './mention-node'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx
index 600f5b6c9ba..b56094f2e0b 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
+import { useCopyToClipboard } from '@sim/emcn'
import { getMarkRange } from '@tiptap/core'
import type { Editor } from '@tiptap/react'
import { Check, Copy, Pencil, Unlink } from 'lucide-react'
import { createPortal } from 'react-dom'
-import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { normalizeLinkHref } from '../markdown-fidelity'
import { applyLink, LinkUrlInput } from './link-editing'
import { ToolbarButton } from './toolbar-button'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx
index 5a34b513ae4..c446e011fa0 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-list.tsx
@@ -1,6 +1,6 @@
import { type ReactNode, type RefObject, useEffect } from 'react'
+import { cn } from '@sim/emcn'
import type { Editor } from '@tiptap/core'
-import { cn } from '@/lib/core/utils/cn'
import {
SUGGESTION_GROUP_LABEL_CLASS,
SUGGESTION_ITEM_CLASS,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx
index f2c9a4a1b51..7802b3197f4 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx
@@ -1,6 +1,5 @@
+import { cn, Tooltip } from '@sim/emcn'
import type { LucideIcon } from 'lucide-react'
-import { Tooltip } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
interface ToolbarButtonProps {
icon: LucideIcon
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
index a1f49048b84..f8a8286d221 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
@@ -1,12 +1,11 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
+import { cn, toast } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import { useRouter } from 'next/navigation'
-import { toast } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
import type { SaveStatus } from '@/hooks/use-autosave'
@@ -27,7 +26,7 @@ import { EditorBubbleMenu } from './menus/bubble-menu'
import { LinkHoverCard } from './menus/link-hover-card'
import { normalizeMarkdownContent } from './normalize-content'
import { isRoundTripSafe } from './round-trip-safety'
-import '@/components/emcn/components/code/code.css'
+import '@sim/emcn/components/code/code.css'
import './rich-markdown-editor.css'
const EXTENSIONS = createMarkdownEditorExtensions({
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx
index a109a4cb2ed..0df1030a7a8 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx
@@ -1,10 +1,9 @@
'use client'
import { useEffect, useRef, useState } from 'react'
+import { ChipTextarea, chipFieldSurfaceClass, cn } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { EditorContent, useEditor } from '@tiptap/react'
-import { ChipTextarea, chipFieldSurfaceClass } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { createMarkdownEditorExtensions } from './editor-extensions'
import {
applyFrontmatter,
@@ -17,7 +16,7 @@ import { EditorBubbleMenu } from './menus/bubble-menu'
import { LinkHoverCard } from './menus/link-hover-card'
import { normalizeMarkdownContent } from './normalize-content'
import { isRoundTripSafe } from './round-trip-safety'
-import '@/components/emcn/components/code/code.css'
+import '@sim/emcn/components/code/code.css'
import './rich-markdown-editor.css'
interface RichMarkdownFieldProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
index ca3a2e27e2b..d0fa0369a1a 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
@@ -2,9 +2,9 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import type { OnMount } from '@monaco-editor/react'
+import { cn } from '@sim/emcn'
import type { editor as MonacoEditorTypes } from 'monaco-editor'
import dynamic from 'next/dynamic'
-import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { EditorContextMenu } from './editor-context-menu'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
index 3962aaf038d..6e15f70cde2 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
@@ -1,10 +1,10 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
+import { Chip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { WorkBook } from 'xlsx'
-import { Chip } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { DataTable } from './data-table'
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx
index a0a6acbbb5c..327e2808393 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx
@@ -2,7 +2,7 @@
import type { MouseEvent, ReactNode } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
import { PreviewToolbar } from './preview-toolbar'
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx
index 1954a1fcb05..9d6308e761a 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx
@@ -1,13 +1,8 @@
'use client'
import { memo } from 'react'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/emcn'
-import { FolderPlus, Plus, Upload } from '@/components/emcn/icons'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
+import { FolderPlus, Plus, Upload } from '@sim/emcn/icons'
interface FilesListContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
index c485697230c..d4e1105e087 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
@@ -1,7 +1,6 @@
'use client'
import { useState } from 'react'
-import { generateShortId } from '@sim/utils/id'
import {
ButtonGroup,
ButtonGroupItem,
@@ -12,8 +11,9 @@ import {
ChipModalHeader,
TagInput,
type TagItem,
-} from '@/components/emcn'
-import { Send } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Send } from '@sim/emcn/icons'
+import { generateShortId } from '@sim/utils/id'
import { GeneratedPasswordInput } from '@/components/ui'
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
import { getEnv, isTruthy } from '@/lib/core/config/env'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index c120b973a93..cbc9828cd53 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -1,11 +1,6 @@
'use client'
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage, toError } from '@sim/utils/errors'
-import { useParams, useRouter } from 'next/navigation'
-import { useQueryStates } from 'nuqs'
-import { usePostHog } from 'posthog-js/react'
import {
Button,
ChipCombobox,
@@ -22,8 +17,13 @@ import {
Trash,
toast,
Upload,
-} from '@/components/emcn'
-import { Download, Send } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Download, Send } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage, toError } from '@sim/utils/errors'
+import { useParams, useRouter } from 'next/navigation'
+import { useQueryStates } from 'nuqs'
+import { usePostHog } from 'posthog-js/react'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useLimitUpgradeToast } from '@/lib/billing/client'
import { captureEvent } from '@/lib/posthog/client'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/loading.tsx b/apps/sim/app/workspace/[workspaceId]/files/loading.tsx
index 486b03004f9..0cae5e550d0 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/loading.tsx
@@ -1,6 +1,6 @@
'use client'
-import { File as FilesIcon, FolderPlus, Plus, Upload } from '@/components/emcn'
+import { File as FilesIcon, FolderPlus, Plus, Upload } from '@sim/emcn'
import {
type ChromeActionSpec,
ResourceChromeFallback,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx
index 3909673616e..c58d842c5e0 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx
@@ -5,8 +5,8 @@ import {
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
-} from '@/components/emcn'
-import { Folder } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Folder } from '@sim/emcn/icons'
export interface MoveOptionNode {
value: string
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx
index e01cbc5f80d..fde79283857 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx
@@ -7,7 +7,7 @@ import {
Table as TableIcon,
Task,
Workflow,
-} from '@/components/emcn/icons'
+} from '@sim/emcn/icons'
import { AgentSkillsIcon } from '@/components/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
index df922d9715e..c4626ad23d0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
@@ -1,5 +1,5 @@
+import { cn } from '@sim/emcn'
import { getDocumentIcon } from '@/components/icons/document-icons'
-import { cn } from '@/lib/core/utils/cn'
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
function FileAttachmentPill(props: { mediaType: string; filename: string }) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
index ad314077b99..2cf39ea8b49 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
@@ -1,10 +1,10 @@
'use client'
import { useCallback } from 'react'
+import { Chip } from '@sim/emcn'
+import { Credit } from '@sim/emcn/icons'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
-import { Chip } from '@/components/emcn'
-import { Credit } from '@/components/emcn/icons'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { formatCredits } from '@/lib/billing/credits/conversion'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
index 4ac5bd3533f..ce8d57b6dcd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
@@ -1,8 +1,7 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
-import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+import { ChevronDown, cn, Expandable, ExpandableContent, PillsRing } from '@sim/emcn'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon, isToolDone } from '../../utils'
import { ToolCallItem } from './tool-call-item'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
index 6b8baa463fa..c671fc3a593 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
-import { PillsRing } from '@/components/emcn'
+import { PillsRing } from '@sim/emcn'
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import type { ToolCallStatus } from '../../../../types'
import { getToolIcon, resolveToolDisplayState } from '../../utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 3075698e179..afeac25cc3b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -3,13 +3,15 @@
import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
+// prismjs core must load before its language components — they register on the
+// global `Prism` it installs (on `window`/`global`); fixes SSR + client order.
+import 'prismjs'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
-import '@/components/emcn/components/code/code.css'
-import { Checkbox, CopyCodeButton, highlight, languages } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+import '@sim/emcn/components/code/code.css'
+import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import {
type ContentSegment,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index f434d983c1c..e8de183ad03 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -1,22 +1,32 @@
'use client'
import { createElement, useMemo, useState } from 'react'
-import { useParams } from 'next/navigation'
import {
ArrowRight,
+ Button,
ChevronDown,
+ cn,
Expandable,
ExpandableContent,
+ SecretInput,
SecretReveal,
-} from '@/components/emcn'
+ Tooltip,
+ toast,
+} from '@sim/emcn'
+import { useParams } from 'next/navigation'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
-import { cn } from '@/lib/core/utils/cn'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type {
ChatMessageContext,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import {
+ usePersonalEnvironment,
+ useSavePersonalEnvironment,
+ useUpsertWorkspaceEnvironment,
+} from '@/hooks/queries/environment'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
@@ -49,15 +59,27 @@ export const CREDENTIAL_TAG_TYPES = [
'sim_key',
'credential_id',
'link',
+ 'secret_input',
] as const
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
+export const SECRET_INPUT_SCOPES = ['personal', 'workspace'] as const
+
+export type SecretInputScope = (typeof SECRET_INPUT_SCOPES)[number]
+
export interface CredentialTagData {
value?: string
type: CredentialTagType
provider?: string
redacted?: boolean
+ /**
+ * Env-var key name to save the pasted secret under (secret_input only),
+ * e.g. "OPENAI_API_KEY".
+ */
+ name?: string
+ /** Where a secret_input value is persisted. Defaults to "workspace". */
+ scope?: SecretInputScope
}
export interface MothershipErrorTagData {
@@ -156,6 +178,17 @@ function isCredentialTagData(value: unknown): value is CredentialTagData {
return false
}
if (value.provider !== undefined && typeof value.provider !== 'string') return false
+ // secret_input is an empty input the user fills in — it carries a key name to
+ // save under, not a value.
+ if (value.type === 'secret_input') {
+ if (
+ value.scope !== undefined &&
+ !(SECRET_INPUT_SCOPES as readonly string[]).includes(value.scope as string)
+ ) {
+ return false
+ }
+ return typeof value.name === 'string' && value.name.trim().length > 0
+ }
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
return typeof value.value === 'string'
}
@@ -619,9 +652,108 @@ const LockIcon = (props: { className?: string }) => (
)
+/**
+ * Inline "paste a secret" widget rendered for
+ * `{"type":"secret_input","name":"OPENAI_API_KEY"} `.
+ * Reuses the shared emcn SecretInput; the pasted value is saved straight to
+ * workspace (default) or personal environment variables under `name` and never
+ * flows back through the chat transcript.
+ */
+function SecretInputDisplay({ data }: { data: CredentialTagData }) {
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const secretName = (data.name ?? '').trim()
+ const scope: SecretInputScope = data.scope === 'personal' ? 'personal' : 'workspace'
+
+ const [value, setValue] = useState('')
+ const [saved, setSaved] = useState(false)
+
+ const upsertWorkspace = useUpsertWorkspaceEnvironment()
+ const savePersonal = useSavePersonalEnvironment()
+ const personalQuery = usePersonalEnvironment()
+ const personalEnv = personalQuery.data
+ const { canEdit } = useUserPermissionsContext()
+
+ // Setting a workspace var needs write/admin (same gate as the secrets manager);
+ // personal vars are the user's own, so any member may set them.
+ const canManage = scope === 'personal' || canEdit
+
+ const isSaving = upsertWorkspace.isPending || savePersonal.isPending
+ // Personal saves replace the whole map, so block until existing vars are loaded.
+ const personalReady = scope !== 'personal' || personalEnv !== undefined
+ const canSave =
+ canManage && secretName.length > 0 && value.trim().length > 0 && !isSaving && personalReady
+
+ const handleSave = async () => {
+ if (!canSave) return
+ try {
+ if (scope === 'personal') {
+ // The personal POST replaces the whole map, so re-read the latest vars
+ // right before merging — a stale snapshot would drop keys saved elsewhere.
+ const { data: latest } = await personalQuery.refetch()
+ const merged: Record = {}
+ for (const [key, entry] of Object.entries(latest ?? personalEnv ?? {}))
+ merged[key] = entry.value
+ merged[secretName] = value
+ await savePersonal.mutateAsync({ variables: merged })
+ } else {
+ await upsertWorkspace.mutateAsync({ workspaceId, variables: { [secretName]: value } })
+ }
+ setValue('')
+ setSaved(true)
+ toast.success(`Saved ${secretName}`)
+ } catch {
+ toast.error(`Couldn't save ${secretName}. Please try again.`)
+ }
+ }
+
+ if (!secretName) return null
+ // Only confirm after the user saves via THIS widget. A fresh prompt always shows
+ // the input so the user can set or override the key, even if it already exists.
+ if (saved) return
+ if (!canManage) return null
+
+ return (
+ {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ void handleSave()
+ }
+ }}
+ endAdornment={
+
+
+ void handleSave()}
+ disabled={!canSave}
+ aria-label='Save'
+ >
+
+
+
+ {isSaving ? 'Saving…' : 'Save'}
+
+ }
+ />
+ )
+}
+
function CredentialDisplay({ data }: { data: CredentialTagData }) {
+ const { canEdit } = useUserPermissionsContext()
+
+ if (data.type === 'secret_input') {
+ return
+ }
+
if (data.type === 'link') {
- if (!data.provider) return null
+ // Connecting a credential mutates the workspace — hide it from read-only members.
+ if (!data.provider || !canEdit) return null
const Icon = getCredentialIcon(data.provider) ?? LockIcon
return (
tagFilterEntries
- .filter((f) => f.tagSlot && f.value.trim())
+ .filter((f) => {
+ if (!f.tagSlot || !f.value.trim()) return false
+ // A `between` filter only applies once both bounds are set. Sending it
+ // with just the lower bound would be rejected at the API boundary and
+ // break the whole list while the user is still entering the range.
+ if (f.operator === 'between' && !f.valueTo.trim()) return false
+ return true
+ })
.map((f) => ({
tagSlot: f.tagSlot,
fieldType: f.fieldType,
operator: f.operator,
value: f.value,
- ...(f.operator === 'between' && f.valueTo ? { valueTo: f.valueTo } : {}),
+ ...(f.operator === 'between' ? { valueTo: f.valueTo } : {}),
})),
[tagFilterEntries]
)
@@ -1466,11 +1473,25 @@ const createEmptyEntry = (): TagFilterEntry => ({
tagName: '',
tagSlot: '',
fieldType: 'text',
- operator: 'eq',
+ operator: 'contains',
value: '',
valueTo: '',
})
+/**
+ * Default operator when a tag is selected. Text filters default to `contains`
+ * so typing part of a value finds matches (exact `equals` stays one click away
+ * in the operator dropdown); other field types keep their first, equality
+ * operator.
+ */
+function getDefaultOperatorForFieldType(
+ fieldType: FilterFieldType,
+ operators: ReturnType
+): string {
+ if (fieldType === 'text') return 'contains'
+ return operators[0]?.value ?? 'eq'
+}
+
interface TagFilterSectionProps {
tagDefinitions: TagDefinition[]
entries: TagFilterEntry[]
@@ -1601,7 +1622,7 @@ function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectio
tagName,
tagSlot: def?.tagSlot || '',
fieldType,
- operator: operators[0]?.value || 'eq',
+ operator: getDefaultOperatorForFieldType(fieldType, operators),
value: '',
valueTo: '',
})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
index f82e10a7ea8..052cd60622b 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
@@ -1,7 +1,6 @@
+import { Button, cn, Tooltip, Trash2 } from '@sim/emcn'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { Circle, CircleOff } from 'lucide-react'
-import { Button, Tooltip, Trash2 } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
interface ActionBarProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
index c32f75b5ec9..c3eb6292a23 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
-import { ArrowLeft, Plus } from 'lucide-react'
-import { useParams } from 'next/navigation'
import {
ArrowRight,
Button,
@@ -18,11 +16,13 @@ import {
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
+ cn,
+ handleKeyboardActivation,
Search,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { ArrowLeft, Plus } from 'lucide-react'
+import { useParams } from 'next/navigation'
import { getSubscriptionAccessState } from '@/lib/billing/client'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
index 4b6967a3552..cfdc62d32af 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
@@ -1,9 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { RotateCcw, X } from 'lucide-react'
-import { useParams } from 'next/navigation'
import {
Button,
ChipModal,
@@ -12,9 +9,12 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ cn,
Loader,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { RotateCcw, X } from 'lucide-react'
+import { useParams } from 'next/navigation'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx
index dc1a1a51f0b..f0faed182c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx
@@ -1,7 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
import {
Button,
ChipCombobox,
@@ -13,10 +12,11 @@ import {
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
+ handleKeyboardActivation,
Trash,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
import type { TagUsageData } from '@/lib/api/contracts/knowledge'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
index e593f728927..340a9992d7e 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
@@ -1,7 +1,7 @@
'use client'
+import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@sim/emcn'
import { ArrowLeftRight, Info } from 'lucide-react'
-import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@/components/emcn'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
import type {
ConfigFieldMap,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx
index ac16b24c93a..f0b855bb229 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx
@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
-import { ChipCombobox, type ComboboxOption, Loader } from '@/components/emcn'
+import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type {
ConfigFieldMap,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
index 81769e2aa91..49c852eb666 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
+import { Badge, Button, Checkbox, ChipConfirmModal, cn, Loader, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { format, formatDistanceToNow, isPast } from 'date-fns'
import {
@@ -15,8 +16,6 @@ import {
Trash,
XCircle,
} from 'lucide-react'
-import { Badge, Button, Checkbox, ChipConfirmModal, Loader, Tooltip } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
index 04b17c69f4c..bb751cf2502 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
@@ -6,8 +6,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
interface DocumentContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
index ab1233f0ed7..35f7a667b60 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { ExternalLink, RotateCcw } from 'lucide-react'
import {
Button,
ButtonGroup,
@@ -16,7 +14,9 @@ import {
ChipModalTabs,
Skeleton,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { ExternalLink, RotateCcw } from 'lucide-react'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx
index 1295da05029..5825d796b87 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import {
ChipModal,
ChipModalBody,
@@ -10,7 +8,9 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
const logger = createLogger('RenameDocumentModal')
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
index 0a79d94c593..975b4ab9560 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
@@ -1,7 +1,7 @@
'use client'
-import { Plus } from '@/components/emcn'
-import { Database } from '@/components/emcn/icons'
+import { Plus } from '@sim/emcn'
+import { Database } from '@sim/emcn/icons'
import {
type BreadcrumbItem,
type ChromeActionSpec,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx
index 5c15066b600..47a33dd0a2a 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx
@@ -1,9 +1,9 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
+import { Badge, DocumentAttachment, Tooltip } from '@sim/emcn'
import { formatAbsoluteDate, formatRelativeTime } from '@sim/utils/formatting'
import { useParams, useRouter } from 'next/navigation'
-import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
index e1d4c1814ca..915b2b69e38 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
@@ -2,12 +2,6 @@
import { memo, useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { X } from 'lucide-react'
-import { useParams } from 'next/navigation'
-import { useForm } from 'react-hook-form'
-import { z } from 'zod'
import {
Button,
Checkbox,
@@ -21,10 +15,16 @@ import {
ChipModalHeader,
ChipTextarea,
type ComboboxOption,
+ cn,
Loader,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { X } from 'lucide-react'
+import { useParams } from 'next/navigation'
+import { useForm } from 'react-hook-form'
+import { z } from 'zod'
import type { StrategyOptions } from '@/lib/chunkers/types'
-import { cn } from '@/lib/core/utils/cn'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx
index 23f24f3fb9c..5d99ed22be3 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx
@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
-import { ChipConfirmModal } from '@/components/emcn'
+import { ChipConfirmModal } from '@sim/emcn'
interface DeleteKnowledgeBaseModalProps {
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx
index 0a0192b5b61..8cb0833c1c6 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { memo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import {
ChipModal,
ChipModalBody,
@@ -10,7 +8,9 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import type { ChunkingConfig } from '@/lib/knowledge/types'
const logger = createLogger('EditKnowledgeBaseModal')
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx
index dc2702ce252..5aead1ae911 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx
@@ -7,8 +7,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Duplicate, Pencil, SquareArrowUpRight, TagIcon, Trash } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Duplicate, Pencil, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
interface KnowledgeBaseContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx
index 1a257be1236..9c5037bfade 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx
@@ -1,13 +1,8 @@
'use client'
import { memo } from 'react'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Plus } from '@/components/emcn/icons'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
+import { Plus } from '@sim/emcn/icons'
interface KnowledgeListContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
index 583737615c3..be7b5ccd801 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -1,11 +1,11 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { ChipDropdownOption } from '@sim/emcn'
+import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn'
+import { Database } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
-import type { ChipDropdownOption } from '@/components/emcn'
-import { Button, ChipDropdown, Plus, Tooltip } from '@/components/emcn'
-import { Database } from '@/components/emcn/icons'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type {
FilterTag,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx
index aba09ccb463..0f788bda4e6 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx
@@ -1,7 +1,7 @@
'use client'
-import { Plus } from '@/components/emcn'
-import { Database } from '@/components/emcn/icons'
+import { Plus } from '@sim/emcn'
+import { Database } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index ddafee4e91c..adbaa7d368a 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -1,7 +1,7 @@
+import { ToastProvider } from '@sim/emcn'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
-import { ToastProvider } from '@/components/emcn'
import { getSession } from '@/lib/auth'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
index 2f8375c836f..4784cf778e9 100644
--- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts
@@ -12,7 +12,7 @@ vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({
prefetchInternalJson: mockPrefetchInternalJson,
}))
-vi.mock('@/components/emcn', () => ({
+vi.mock('@sim/emcn', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx
index 5faf30a5872..5067b579835 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx
@@ -1,7 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
+import { Button, cn } from '@sim/emcn'
import { generateShortId } from '@sim/utils/id'
-import { Button } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
export interface LineChartPoint {
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx
index d78102a019a..0cac86a2ec7 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx
@@ -1,5 +1,5 @@
import { memo, useMemo, useState } from 'react'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
+import { handleKeyboardActivation } from '@sim/emcn'
import {
type SegmentSelectionMode,
useDashboardSegments,
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx
index 52b0ac60ec7..0fb57df945d 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx
@@ -1,7 +1,6 @@
import { memo } from 'react'
-import { Workflow } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
+import { cn, handleKeyboardActivation } from '@sim/emcn'
+import { Workflow } from '@sim/emcn/icons'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { StatusBar, type StatusBarSegment } from '..'
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx
index ce53588e763..af9aae6d90d 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx
@@ -1,8 +1,8 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
+import { Loader } from '@sim/emcn'
import { useParams } from 'next/navigation'
-import { Loader } from '@/components/emcn'
import {
DashboardSegmentsContext,
type SegmentSelectionMode,
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx
index 955955e8c4d..c1eb4880f7a 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx
@@ -2,9 +2,8 @@
import type React from 'react'
import { useState } from 'react'
-import { AlertCircle } from 'lucide-react'
-import { createPortal } from 'react-dom'
import {
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -16,8 +15,9 @@ import {
ModalContent,
ModalDescription,
ModalHeader,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { AlertCircle } from 'lucide-react'
+import { createPortal } from 'react-dom'
import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
index ca40674b3ef..f898833b891 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
@@ -1,9 +1,9 @@
'use client'
+import { Button } from '@sim/emcn'
+import { Download } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
-import { Button } from '@/components/emcn'
-import { Download } from '@/components/emcn/icons'
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('FileCards')
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
index f1f9fe02558..bacca64af3b 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
@@ -2,24 +2,13 @@
import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { formatDuration } from '@sim/utils/formatting'
-import {
- ArrowDown,
- ArrowUp,
- Check,
- ChevronsDownUp,
- ChevronsUpDown,
- Clipboard,
- Search,
- X,
-} from 'lucide-react'
-import { createPortal } from 'react-dom'
import {
Badge,
Button,
ChevronDown,
ChipInput,
Code,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -28,8 +17,20 @@ import {
Duplicate,
Search as SearchIcon,
Tooltip,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+ useCopyToClipboard,
+} from '@sim/emcn'
+import { formatDuration } from '@sim/utils/formatting'
+import {
+ ArrowDown,
+ ArrowUp,
+ Check,
+ ChevronsDownUp,
+ ChevronsUpDown,
+ Clipboard,
+ Search,
+ X,
+} from 'lucide-react'
+import { createPortal } from 'react-dom'
import type { TraceSpan } from '@/lib/logs/types'
import {
adjustBgForContrast,
@@ -46,7 +47,6 @@ import {
parseTime,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
-import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
const DEFAULT_TREE_PANE_WIDTH = 240
const MIN_TREE_PANE_WIDTH = 200
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
index 37a63dd28dd..e28a8036242 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
@@ -1,15 +1,12 @@
'use client'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { formatDuration } from '@sim/utils/formatting'
-import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
-import { useQueryState } from 'nuqs'
-import { createPortal } from 'react-dom'
import {
Button,
ChipInput,
ChipModalTabs,
Code,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -17,16 +14,20 @@ import {
DropdownMenuTrigger,
Duplicate,
Eye,
+ handleKeyboardActivation,
Redo,
Search as SearchIcon,
Tooltip,
-} from '@/components/emcn'
-import { Workflow } from '@/components/emcn/icons'
+ useCopyToClipboard,
+} from '@sim/emcn'
+import { Workflow } from '@sim/emcn/icons'
+import { formatDuration } from '@sim/utils/formatting'
+import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
+import { useQueryState } from 'nuqs'
+import { createPortal } from 'react-dom'
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import {
@@ -47,7 +48,6 @@ import {
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
-import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { formatCost } from '@/providers/utils'
import { useLogDetailsUIStore } from '@/stores/logs/store'
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx
index f9c4d743538..0b2f7c11a2b 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx
@@ -14,7 +14,7 @@ import {
Redo,
SquareArrowUpRight,
X,
-} from '@/components/emcn'
+} from '@sim/emcn'
import type { WorkflowLogSummary } from '@/lib/api/contracts/logs'
interface LogRowContextMenuProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx b/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx
index 4b1c259bf74..4e0adcf8390 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx
@@ -5,7 +5,7 @@ import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-vi.mock('@/components/emcn', () => ({
+vi.mock('@sim/emcn', () => ({
Button: ({ children, ...props }: { children: ReactNode } & Record) => (
{children}
),
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx b/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
index c885da5666a..2b25d77ca74 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
@@ -1,7 +1,7 @@
'use client'
-import { Library, RefreshCw } from '@/components/emcn'
-import { Download } from '@/components/emcn/icons'
+import { Library, RefreshCw } from '@sim/emcn'
+import { Download } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
index 86040119fa4..cfdeeaad72d 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
@@ -9,30 +9,30 @@ import {
useRef,
useState,
} from 'react'
-import { formatDuration } from '@sim/utils/formatting'
-import { useQueryClient } from '@tanstack/react-query'
-import { useParams } from 'next/navigation'
-import { useQueryState } from 'nuqs'
import {
Button,
+ Calendar,
ChipCombobox,
type ComboboxOption,
+ cn,
Library,
Popover,
PopoverAnchor,
PopoverContent,
RefreshCw,
toast,
-} from '@/components/emcn'
-import { Calendar } from '@/components/emcn/components/calendar/calendar'
-import { Download, Workflow } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Download, Workflow } from '@sim/emcn/icons'
+import { formatDuration } from '@sim/utils/formatting'
+import { useQueryClient } from '@tanstack/react-query'
+import { useParams } from 'next/navigation'
+import { useQueryState } from 'nuqs'
import type {
WorkflowLogDetail,
WorkflowLogRow,
WorkflowLogSummary,
} from '@/lib/api/contracts/logs'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
-import { cn } from '@/lib/core/utils/cn'
import {
getEndDateFromTimeRange,
getStartDateFromTimeRange,
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
index da6b50613a5..a1553590097 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
@@ -1,7 +1,7 @@
import React from 'react'
+import { Badge } from '@sim/emcn'
import { formatDuration } from '@sim/utils/formatting'
import { format } from 'date-fns'
-import { Badge } from '@/components/emcn'
import type { WorkflowLogDetail } from '@/lib/api/contracts/logs'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
@@ -72,7 +72,6 @@ const TRIGGER_VARIANT_MAP: Record['va
chat: 'purple',
webhook: 'orange',
mcp: 'cyan',
- a2a: 'teal',
copilot: 'pink',
mothership: 'pink',
workflow: 'blue-secondary',
diff --git a/apps/sim/app/workspace/[workspaceId]/not-found.tsx b/apps/sim/app/workspace/[workspaceId]/not-found.tsx
index ed65206a744..43dbfbaea36 100644
--- a/apps/sim/app/workspace/[workspaceId]/not-found.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/not-found.tsx
@@ -1,10 +1,10 @@
'use client'
+import { Button, buttonVariants } from '@sim/emcn'
+import { ArrowLeft, Home } from '@sim/emcn/icons'
import { Compass } from 'lucide-react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
-import { Button, buttonVariants } from '@/components/emcn'
-import { ArrowLeft, Home } from '@/components/emcn/icons'
import { ErrorShell } from '@/app/workspace/[workspaceId]/components'
export default function WorkspaceNotFound() {
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
index b6bcecf4054..ff28e7309a9 100644
--- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx
@@ -2,10 +2,10 @@
import type React from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
+import { useToast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
-import { useToast } from '@/components/emcn'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import {
useWorkspacePermissionsQuery,
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx
index 54198191861..785b0bb9d94 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx
@@ -1,8 +1,6 @@
'use client'
-
+import { chipContentGap, chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
-import { chipContentGap, chipPrimaryFillTokens } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import type {
CalendarEvent,
ScheduledTask,
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx
index 4e51b99e730..f45d7490186 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx
@@ -1,7 +1,5 @@
'use client'
-import { format, parseISO } from 'date-fns'
-import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
Check,
Chip,
@@ -10,7 +8,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { format, parseISO } from 'date-fns'
+import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { CalendarScope } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
const SCOPE_OPTIONS: { value: CalendarScope; label: string }[] = [
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx
index b5ce798812d..17cde723105 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx
@@ -1,8 +1,6 @@
'use client'
-
+import { chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
-import { chipPrimaryFillTokens } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
import {
type CalendarDayCell,
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx
index 63d4b64c88d..4f76fb5520e 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx
@@ -1,9 +1,8 @@
'use client'
import { useEffect, useState } from 'react'
+import { chipPrimaryFillTokens, cn } from '@sim/emcn'
import { format } from 'date-fns'
-import { chipPrimaryFillTokens } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { zonedClockDate } from '@/lib/core/utils/timezone'
import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/schedule-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/schedule-list-context-menu.tsx
index f23ad12eb7d..dccceda7ac2 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/schedule-list-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/schedule-list-context-menu.tsx
@@ -1,12 +1,7 @@
'use client'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Plus } from '@/components/emcn/icons'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
+import { Plus } from '@sim/emcn/icons'
interface ScheduleListContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx
index 9da810f20a0..f7bf16ba921 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx
@@ -6,8 +6,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@sim/emcn/icons'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface TaskContextMenuProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx
index c1ecaadd4fb..d4dcbc3f2d3 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx
@@ -7,7 +7,7 @@ import {
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
-} from '@/components/emcn'
+} from '@sim/emcn'
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
interface TaskDeleteDialogProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx
index 2674622703a..7c2f0103be8 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx
@@ -1,7 +1,4 @@
'use client'
-
-import { format } from 'date-fns'
-import { useParams } from 'next/navigation'
import {
Calendar,
ChipModal,
@@ -10,8 +7,10 @@ import {
ChipModalFooter,
ChipModalHeader,
chipFieldSurfaceClass,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+ cn,
+} from '@sim/emcn'
+import { format } from 'date-fns'
+import { useParams } from 'next/navigation'
import {
PromptEditor,
usePromptEditor,
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx
index bc7fdf2094f..3453b88a958 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx
@@ -1,14 +1,14 @@
'use client'
import { useRef } from 'react'
-import { format } from 'date-fns'
import {
CalendarDayCell,
ChipDatePicker,
ChipModalField,
ChipModalSeparator,
Switch,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { format } from 'date-fns'
import type {
MonthlyMode,
Recurrence,
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx
index 0d05b31fdd9..4862bcc0739 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import { format } from 'date-fns'
-import { useParams } from 'next/navigation'
import {
Calendar,
ChipDatePicker,
@@ -12,7 +10,9 @@ import {
ChipModalHeader,
ChipModalPromptBody,
ChipTimePicker,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { format } from 'date-fns'
+import { useParams } from 'next/navigation'
import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone'
import {
PromptEditor,
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/loading.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/loading.tsx
index d6850e44e2e..6f3c7c88618 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/loading.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Calendar, Plus } from '@/components/emcn'
+import { Calendar, Plus } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx
index 9acdf7cf835..8f3410fe014 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx
@@ -1,8 +1,8 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
+import { Calendar, Plus } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
-import { Calendar, Plus } from '@/components/emcn'
import type { ResourceAction } from '@/app/workspace/[workspaceId]/components'
import { Resource } from '@/app/workspace/[workspaceId]/components'
import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx
new file mode 100644
index 00000000000..6ab029f7f5b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx
@@ -0,0 +1,18 @@
+import {
+ SettingsHeaderProvider,
+ SettingsHeaderShell,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
+
+/**
+ * Persistent chrome for the settings panel pages. The header bar, title,
+ * description, scroll region, and centered column live in the shell and stay
+ * mounted across section navigation — only the body swaps. Scoped to `[section]`
+ * so detail routes (e.g. `secrets/[credentialId]`) keep their own chrome.
+ */
+export default function SettingsSectionLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index 7f4dcf3f1f2..b2fc4a7daa5 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -129,30 +129,28 @@ export function SettingsPage({ section }: SettingsPageProps) {
return (
-
- {effectiveSection === 'general' &&
}
- {effectiveSection === 'secrets' &&
}
- {effectiveSection === 'credential-sets' &&
}
- {effectiveSection === 'access-control' &&
}
- {effectiveSection === 'audit-logs' &&
}
- {effectiveSection === 'apikeys' &&
}
- {isBillingEnabled && effectiveSection === 'billing' &&
}
- {effectiveSection === 'teammates' &&
}
- {isBillingEnabled && effectiveSection === 'organization' &&
}
- {effectiveSection === 'sso' &&
}
- {effectiveSection === 'data-retention' &&
}
- {effectiveSection === 'data-drains' &&
}
- {effectiveSection === 'whitelabeling' &&
}
- {effectiveSection === 'byok' &&
}
- {effectiveSection === 'copilot' &&
}
- {effectiveSection === 'mcp' &&
}
- {effectiveSection === 'custom-tools' &&
}
- {effectiveSection === 'workflow-mcp-servers' &&
}
- {effectiveSection === 'inbox' &&
}
- {effectiveSection === 'recently-deleted' &&
}
- {effectiveSection === 'admin' &&
}
- {effectiveSection === 'mothership' &&
}
-
+ {effectiveSection === 'general' && }
+ {effectiveSection === 'secrets' && }
+ {effectiveSection === 'credential-sets' && }
+ {effectiveSection === 'access-control' && }
+ {effectiveSection === 'audit-logs' && }
+ {effectiveSection === 'apikeys' && }
+ {isBillingEnabled && effectiveSection === 'billing' && }
+ {effectiveSection === 'teammates' && }
+ {isBillingEnabled && effectiveSection === 'organization' && }
+ {effectiveSection === 'sso' && }
+ {effectiveSection === 'data-retention' && }
+ {effectiveSection === 'data-drains' && }
+ {effectiveSection === 'whitelabeling' && }
+ {effectiveSection === 'byok' && }
+ {effectiveSection === 'copilot' && }
+ {effectiveSection === 'mcp' && }
+ {effectiveSection === 'custom-tools' && }
+ {effectiveSection === 'workflow-mcp-servers' && }
+ {effectiveSection === 'inbox' && }
+ {effectiveSection === 'recently-deleted' && }
+ {effectiveSection === 'admin' && }
+ {effectiveSection === 'mothership' && }
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
index bca74138132..56e5a9e33aa 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
@@ -1,13 +1,12 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Badge, Button, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
-import { Badge, Button, ChipInput, ChipSelect, Label, Search, Switch } from '@/components/emcn'
import type { MothershipEnvironment } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
-import { cn } from '@/lib/core/utils/cn'
import {
adminParsers,
adminUrlKeys,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
index 8c388d89a48..1fefe86ba36 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
@@ -1,15 +1,16 @@
'use client'
import { useMemo, useState } from 'react'
+import { ChipConfirmModal, Switch, Tooltip, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Info, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { Chip, ChipConfirmModal, Switch, Tooltip, toast } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
@@ -132,6 +133,19 @@ export function ApiKeys() {
return formatDate(new Date(dateString))
}
+ const actions: SettingsAction[] = [
+ {
+ text: 'Create API key',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => {
+ if (createButtonDisabled) return
+ setIsCreateDialogOpen(true)
+ },
+ disabled: createButtonDisabled,
+ },
+ ]
+
return (
<>
{
- if (createButtonDisabled) return
- setIsCreateDialogOpen(true)
- }}
- disabled={createButtonDisabled}
- >
- Create API Key
-
- }
+ actions={actions}
>
{isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
- Click "Create API Key" above to get started
+ Click "Create API key" above to get started
) : (
{!searchTerm.trim() ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
index 3bd2d1df658..b8dfb6d975b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import {
ButtonGroup,
ButtonGroupItem,
@@ -13,7 +11,9 @@ import {
ChipModalFooter,
ChipModalHeader,
SecretReveal,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { type ApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
const logger = createLogger('CreateApiKeyModal')
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx
index 36dde10900e..9887f5bd729 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx
@@ -1,10 +1,4 @@
'use client'
-
-import { createLogger } from '@sim/logger'
-import { isOrgAdminRole } from '@sim/platform-authz/predicates'
-import { getErrorMessage } from '@sim/utils/errors'
-import { useQueryClient } from '@tanstack/react-query'
-import { useParams, useRouter } from 'next/navigation'
import {
ArrowRight,
Badge,
@@ -12,9 +6,15 @@ import {
ChipLink,
Credit,
chipVariants,
+ cn,
Switch,
toast,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { isOrgAdminRole } from '@sim/platform-authz/predicates'
+import { getErrorMessage } from '@sim/utils/errors'
+import { useQueryClient } from '@tanstack/react-query'
+import { useParams, useRouter } from 'next/navigation'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
@@ -34,7 +34,6 @@ import {
hasUsableSubscriptionAccess,
} from '@/lib/billing/subscriptions/utils'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
-import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx
index eb347435dff..fd56bcd442c 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx
@@ -1,8 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
+import { ChipInput, Info, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
-import { ChipInput, Info, toast } from '@/components/emcn'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx
index 23e8837e451..8751e926383 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx
@@ -1,9 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { Eye, EyeOff, Search } from 'lucide-react'
import {
Button,
Chip,
@@ -14,8 +11,11 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+ cn,
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { Eye, EyeOff, Search } from 'lucide-react'
import {
CHIP_FIELD_INPUT,
CHIP_FIELD_SHELL,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx
index 46d2fda615d..f60805706c9 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@/components/emcn'
+import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@sim/emcn'
import type {
BYOKManagerKey,
BYOKManagerProvider,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx
index 7a69eb52bf3..71d220b9c37 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/emcn'
+import { Skeleton } from '@sim/emcn'
/**
* Skeleton component for BYOK provider key items.
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
index 7082bbcba29..b02be972520 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx
@@ -1,9 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { formatDate } from '@sim/utils/formatting'
-import { Plus } from 'lucide-react'
import {
Chip,
ChipConfirmModal,
@@ -14,8 +11,12 @@ import {
ChipModalFooter,
ChipModalHeader,
SecretReveal,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { formatDate } from '@sim/utils/formatting'
+import { Plus } from 'lucide-react'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
type CopilotKey,
@@ -103,6 +104,19 @@ export function Copilot() {
const showEmptyState = !hasKeys
const showNoResults = searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0
+ const actions: SettingsAction[] = [
+ {
+ text: 'Create API key',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => {
+ setIsCreateDialogOpen(true)
+ setCreateError(null)
+ },
+ disabled: isLoading,
+ },
+ ]
+
return (
<>
{
- setIsCreateDialogOpen(true)
- setCreateError(null)
- }}
- disabled={isLoading}
- >
- Create API Key
-
- }
+ actions={actions}
>
{isLoading ? null : showEmptyState ? (
- Click "Create API Key" above to get started
+ Click "Create API key" above to get started
) : (
{filteredKeys.map((key) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx
index 8664283de10..acdcc5eb116 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx
@@ -1,9 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { isOrgAdminRole } from '@sim/platform-authz/predicates'
-import { Plus } from 'lucide-react'
import {
Avatar,
AvatarFallback,
@@ -22,8 +19,11 @@ import {
type FileInputOptions,
TagInput,
type TagItem,
-} from '@/components/emcn'
-import { ArrowLeft } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { isOrgAdminRole } from '@sim/platform-authz/predicates'
+import { Plus } from 'lucide-react'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
@@ -420,186 +420,180 @@ export function CredentialSets() {
const totalCount = activeMembers.length + pendingInvitations.length
return (
-
-
-
-
-
-
-
-
- Group Name
- {viewingSet.name}
-
-
-
-
Provider
-
- {getProviderIcon(viewingSet.providerId)}
-
- {getProviderDisplayName(viewingSet.providerId as PollingProvider)}
-
-
-
+
+
+
+
+
+ Group Name
+ {viewingSet.name}
-
-
-
-
-
-
addEmail(value)}
- onRemove={removeEmailItem}
- placeholder='Enter email addresses'
- placeholderWithTags='Add another email'
- disabled={createInvitation.isPending}
- fileInputOptions={fileInputOptions}
- className='flex-1'
- />
-
- {createInvitation.isPending ? 'Sending...' : 'Invite'}
-
+
+
+
Provider
+
+ {getProviderIcon(viewingSet.providerId)}
+
+ {getProviderDisplayName(viewingSet.providerId as PollingProvider)}
+
- {emailError &&
{emailError}
}
-
-
-
- {membersLoading || pendingInvitationsLoading ? null : totalCount === 0 ? (
-
- No members yet. Send invitations above.
-
- ) : (
-
- {activeMembers.map((member) => {
- const name = member.userName || 'Unknown'
- const avatarInitial = name.charAt(0).toUpperCase()
-
- return (
-
-
-
- {member.userImage && }
-
- {avatarInitial}
-
-
-
-
-
-
- {name}
-
- {member.credentials.length === 0 && (
-
- Disconnected
-
- )}
-
-
- {member.userEmail}
-
+
+
+
+
+
+
+ addEmail(value)}
+ onRemove={removeEmailItem}
+ placeholder='Enter email addresses'
+ placeholderWithTags='Add another email'
+ disabled={createInvitation.isPending}
+ fileInputOptions={fileInputOptions}
+ className='flex-1'
+ />
+
+ {createInvitation.isPending ? 'Sending...' : 'Invite'}
+
+
+ {emailError &&
{emailError}
}
+
+
+
+
+ {membersLoading || pendingInvitationsLoading ? null : totalCount === 0 ? (
+
+ No members yet. Send invitations above.
+
+ ) : (
+
+ {activeMembers.map((member) => {
+ const name = member.userName || 'Unknown'
+ const avatarInitial = name.charAt(0).toUpperCase()
+
+ return (
+
+
+
+ {member.userImage && }
+
+ {avatarInitial}
+
+
+
+
+
+
+ {name}
+
+ {member.credentials.length === 0 && (
+
+ Disconnected
+
+ )}
+
+
+ {member.userEmail}
+
-
- handleRemoveMember(member.id),
- },
- ]}
- />
-
+
+ handleRemoveMember(member.id),
+ },
+ ]}
+ />
- )
- })}
-
- {pendingInvitations.map((invitation) => {
- const email = invitation.email || 'Unknown'
- const emailPrefix = email.split('@')[0]
- const avatarInitial = emailPrefix.charAt(0).toUpperCase()
-
- return (
-
-
-
-
- {avatarInitial}
-
-
-
-
-
-
- {emailPrefix}
-
-
- Pending
-
-
-
- {email}
-
+
+ )
+ })}
+
+ {pendingInvitations.map((invitation) => {
+ const email = invitation.email || 'Unknown'
+ const emailPrefix = email.split('@')[0]
+ const avatarInitial = emailPrefix.charAt(0).toUpperCase()
+
+ return (
+
+
+
+
+ {avatarInitial}
+
+
+
+
+
+
+ {emailPrefix}
+
+
+ Pending
+
+
+
+ {email}
+
-
- 0,
- onSelect: () => handleResendInvitation(invitation.id, email),
- },
- {
- label: cancellingInvitations.has(invitation.id)
- ? 'Cancelling...'
- : 'Cancel',
- destructive: true,
- disabled: cancellingInvitations.has(invitation.id),
- onSelect: () => handleCancelInvitation(invitation.id),
- },
- ]}
- />
-
+
+ 0,
+ onSelect: () => handleResendInvitation(invitation.id, email),
+ },
+ {
+ label: cancellingInvitations.has(invitation.id)
+ ? 'Cancelling...'
+ : 'Cancel',
+ destructive: true,
+ disabled: cancellingInvitations.has(invitation.id),
+ onSelect: () => handleCancelInvitation(invitation.id),
+ },
+ ]}
+ />
- )
- })}
-
- )}
-
-
+
+ )
+ })}
+
+ )}
+
-
+
)
}
@@ -612,11 +606,16 @@ export function CredentialSets() {
placeholder: 'Search polling groups...',
}}
actions={
- canManageCredentialSets && (
-
setShowCreateModal(true)}>
- Create Group
-
- )
+ canManageCredentialSets
+ ? [
+ {
+ text: 'Create group',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setShowCreateModal(true),
+ },
+ ]
+ : undefined
}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
index 65411990e73..a537708b167 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
@@ -1,13 +1,14 @@
'use client'
import { useState } from 'react'
+import { ChipConfirmModal } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { Chip, ChipConfirmModal } from '@/components/emcn'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
@@ -86,6 +87,16 @@ export function CustomTools() {
const showEmptyState = !hasTools && !showAddForm && !editingTool
const showNoResults = searchTerm.trim() && filteredTools.length === 0 && tools.length > 0
+ const actions: SettingsAction[] = [
+ {
+ text: 'Add tool',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setShowAddForm(true),
+ disabled: isLoading,
+ },
+ ]
+
return (
<>
setShowAddForm(true)}
- disabled={isLoading}
- >
- Add Tool
-
- }
+ actions={actions}
>
{error ? (
@@ -113,7 +115,7 @@ export function CustomTools() {
) : isLoading ? null : showEmptyState ? (
- Click "Add Tool" above to get started
+ Click "Add tool" above to get started
) : (
{filteredTools.map((tool) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
index cd0418410d3..4d38140a26b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
@@ -1,13 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { Camera, Check, Info, Pencil } from 'lucide-react'
-import Image from 'next/image'
-import { useRouter } from 'next/navigation'
import {
Button,
- Chip,
ChipCombobox,
ChipModal,
ChipModalBody,
@@ -15,20 +10,25 @@ import {
ChipModalFooter,
ChipModalHeader,
ChipSelect,
+ handleKeyboardActivation,
Input,
Label,
Switch,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { Camera, Check, Info, Pencil } from 'lucide-react'
+import Image from 'next/image'
+import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { telemetryContract } from '@/lib/api/contracts/telemetry'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
@@ -269,25 +269,26 @@ export function General() {
return null
}
+ const actions: SettingsAction[] = [
+ ...(isHosted
+ ? [
+ {
+ text: 'Home page',
+ onSelect: () => window.open('/?home', '_blank', 'noopener,noreferrer'),
+ },
+ ]
+ : []),
+ ...(!isAuthDisabled
+ ? [
+ { text: 'Sign out', onSelect: handleSignOut },
+ { text: 'Reset password', onSelect: () => setShowResetPasswordModal(true) },
+ ]
+ : []),
+ ]
+
return (
<>
-
- {isHosted && (
- window.open('/?home', '_blank', 'noopener,noreferrer')}>
- Home Page
-
- )}
- {!isAuthDisabled && (
- <>
- Sign out
- setShowResetPasswordModal(true)}>Reset password
- >
- )}
- >
- }
- >
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
index c49e6564ce4..f3976f33415 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
@@ -1,8 +1,6 @@
'use client'
import { useCallback, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { useParams } from 'next/navigation'
import {
ChipConfirmModal,
ChipModal,
@@ -11,7 +9,9 @@ import {
ChipModalFooter,
ChipModalHeader,
Switch,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { useParams } from 'next/navigation'
import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox'
const logger = createLogger('InboxEnableToggle')
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
index c711a497fae..942fd5d8acd 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
@@ -1,9 +1,6 @@
'use client'
import { useCallback, useState } from 'react'
-import { getErrorMessage } from '@sim/utils/errors'
-import { Check, Clipboard, Pencil, Plus, Trash2 } from 'lucide-react'
-import { useParams } from 'next/navigation'
import {
Badge,
Chip,
@@ -15,7 +12,10 @@ import {
ChipModalFooter,
ChipModalHeader,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { Check, Clipboard, Pencil, Plus, Trash2 } from 'lucide-react'
+import { useParams } from 'next/navigation'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
useAddInboxSender,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx
index f734ffd5a72..c96f587ccea 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx
@@ -1,10 +1,10 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
+import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn'
import { formatRelativeTime } from '@sim/utils/formatting'
import { ArrowRight, Paperclip } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
-import { Badge, ChipInput, ChipSelect, Search } from '@/components/emcn'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { InboxTaskItem } from '@/hooks/queries/inbox'
import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
index 728c01c7017..fd8b348cac7 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
@@ -1,8 +1,8 @@
'use client'
+import { Chip } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
-import { Chip } from '@/components/emcn'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import {
InboxEnableToggle,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
index d92caf069b0..a7bbfc7d4a0 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
@@ -1,9 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { ChevronDown, ChevronRight } from 'lucide-react'
import {
Button,
ChipInput,
@@ -14,9 +11,12 @@ import {
ChipModalFooter,
type ChipModalFooterAction,
ChipModalHeader,
+ cn,
SecretInput,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { ChevronDown, ChevronRight } from 'lucide-react'
import type { McpAuthType, McpTransport } from '@/lib/mcp/types'
import {
checkEnvVarTrigger,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
index a1f60a9e2fc..b0a185065b8 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
@@ -1,16 +1,15 @@
'use client'
import { useEffect, useRef, useState } from 'react'
+import { Badge, Button, Chip, ChipConfirmModal, cn, Tooltip } from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ChevronDown, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
-import { Badge, Button, Chip, ChipConfirmModal, Tooltip } from '@/components/emcn'
-import { ArrowLeft } from '@/components/emcn/icons'
import { requestJson } from '@/lib/api/client/request'
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
-import { cn } from '@/lib/core/utils/cn'
import {
getIssueBadgeLabel,
getIssueBadgeVariant,
@@ -382,202 +381,192 @@ export function MCP() {
? refreshedWorkflowsUpdated
? `Synced (${refreshedWorkflowsUpdated} workflow${refreshedWorkflowsUpdated === 1 ? '' : 's'})`
: 'Refreshed'
- : 'Refresh Tools'
+ : 'Refresh tools'
return (
-
-
-
- MCP Tools
-
-
- handleRefreshServer(server.id)}
- disabled={refreshingServerId === server.id || refreshedServerId === server.id}
- >
- {refreshLabel}
-
- setEditingServerId(server.id)}>Edit
-
-
+
handleRefreshServer(server.id),
+ disabled: refreshingServerId === server.id || refreshedServerId === server.id,
+ },
+ {
+ text: 'Edit',
+ onSelect: () => setEditingServerId(server.id),
+ },
+ ]}
+ >
+
+
+
+
Server Name
+
+ {server.name || 'Unnamed Server'}
+
+
+
+
+
Transport
+
{transportLabel}
+
+
+ {server.url && (
+
+ )}
-
-
-
-
-
-
Server Name
-
- {server.name || 'Unnamed Server'}
-
-
+ {server.connectionStatus === 'error' && (
+
+
Status
+
+ {server.lastError || 'Unable to connect'}
+
+
+ )}
-
-
Transport
-
{transportLabel}
+ {server.authType === 'oauth' && server.connectionStatus !== 'connected' && (
+
+
Authentication
+
+ {
+ await startOauthForServer(server.id)
+ }}
+ >
+ {connectingOauthServers.has(server.id) ? 'Connecting…' : 'Connect with OAuth'}
+
-
- {server.url && (
-
- )}
-
- {server.connectionStatus === 'error' && (
-
-
Status
-
- {server.lastError || 'Unable to connect'}
-
-
- )}
-
- {server.authType === 'oauth' && server.connectionStatus !== 'connected' && (
-
-
Authentication
-
- {
- await startOauthForServer(server.id)
- }}
- >
- {connectingOauthServers.has(server.id)
- ? 'Connecting…'
- : 'Connect with OAuth'}
-
-
-
- )}
-
-
-
- {tools.length === 0 ? (
- No tools available
- ) : (
-
- {tools.map((tool) => {
- const issues = getStoredToolIssues(server.id, tool.name)
- const affectedWorkflows = issues.map((i) => i.workflowName)
- const isExpanded = expandedTools.has(tool.name)
- const hasParams =
- tool.inputSchema?.properties &&
- Object.keys(tool.inputSchema.properties).length > 0
- const requiredParams = tool.inputSchema?.required || []
-
- return (
-
- hasParams && toggleToolExpanded(tool.name)}
+ )}
+
+
+
+
+ {tools.length === 0 ? (
+ No tools available
+ ) : (
+
+ {tools.map((tool) => {
+ const issues = getStoredToolIssues(server.id, tool.name)
+ const affectedWorkflows = issues.map((i) => i.workflowName)
+ const isExpanded = expandedTools.has(tool.name)
+ const hasParams =
+ tool.inputSchema?.properties &&
+ Object.keys(tool.inputSchema.properties).length > 0
+ const requiredParams = tool.inputSchema?.required || []
+
+ return (
+
+
hasParams && toggleToolExpanded(tool.name)}
+ className={cn(
+ 'flex h-auto w-full items-start justify-between rounded-none px-2.5 py-2 text-left text-sm',
+ hasParams && 'cursor-pointer hover-hover:bg-[var(--surface-4)]'
+ )}
+ disabled={!hasParams}
+ >
+
+
+
+ {tool.name}
+
+ {issues.length > 0 && (
+
+
+
+
+ {getIssueBadgeLabel(issues[0].issue)}
+
+
+
+
+ Update in: {affectedWorkflows.join(', ')}
+
+
+ )}
+
+ {tool.description && (
+
+ {tool.description}
+
+ )}
+
+ {hasParams && (
+
-
-
-
- {tool.name}
-
- {issues.length > 0 && (
-
-
-
-
- {getIssueBadgeLabel(issues[0].issue)}
+ />
+ )}
+
+
+ {isExpanded && hasParams && (
+
+
+ Parameters
+
+
+ {Object.entries(tool.inputSchema!.properties!).map(
+ ([paramName, param]) => {
+ const isRequired = requiredParams.includes(paramName)
+ const paramType =
+ typeof param === 'object' && param !== null
+ ? (param as { type?: string }).type || 'any'
+ : 'any'
+ const paramDesc =
+ typeof param === 'object' && param !== null
+ ? (param as { description?: string }).description
+ : undefined
+
+ return (
+
+
+
+ {paramName}
+
+
+ {paramType}
+
+ {isRequired && (
+
+ required
-
-
-
- Update in: {affectedWorkflows.join(', ')}
-
-
- )}
-
- {tool.description && (
-
- {tool.description}
-
- )}
-
- {hasParams && (
-
+ )}
+
+ {paramDesc && (
+
+ {paramDesc}
+
+ )}
+
+ )
+ }
)}
-
-
- {isExpanded && hasParams && (
-
-
- Parameters
-
-
- {Object.entries(tool.inputSchema!.properties!).map(
- ([paramName, param]) => {
- const isRequired = requiredParams.includes(paramName)
- const paramType =
- typeof param === 'object' && param !== null
- ? (param as { type?: string }).type || 'any'
- : 'any'
- const paramDesc =
- typeof param === 'object' && param !== null
- ? (param as { description?: string }).description
- : undefined
-
- return (
-
-
-
- {paramName}
-
-
- {paramType}
-
- {isRequired && (
-
- required
-
- )}
-
- {paramDesc && (
-
- {paramDesc}
-
- )}
-
- )
- }
- )}
-
-
- )}
+
- )
- })}
-
- )}
-
-
-
+ )}
+
+ )
+ })}
+
+ )}
+
-
+
)
}
@@ -613,16 +602,15 @@ export function MCP() {
onChange: setSearchTerm,
placeholder: 'Search MCPs...',
}}
- actions={
-
setShowAddModal(true)}
- disabled={serversLoading}
- >
- Add Server
-
- }
+ actions={[
+ {
+ text: 'Add server',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setShowAddModal(true),
+ disabled: serversLoading,
+ },
+ ]}
>
{error ? (
@@ -631,7 +619,7 @@ export function MCP() {
) : serversLoading ? null : !hasServers ? (
-
Click "Add Server" above to get started
+
Click "Add server" above to get started
) : (
{filteredServers.map((server) => {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
index a2eab8cfb6c..273e9744ec4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
@@ -1,11 +1,10 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
+import { Badge, Button, ChipInput, ChipSelect, cn, Label, Skeleton } from '@sim/emcn'
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
-import { Badge, Button, ChipInput, ChipSelect, Label, Skeleton } from '@/components/emcn'
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
-import { cn } from '@/lib/core/utils/cn'
import {
BYOKKeyManager,
type BYOKManagerProvider,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
index 8dc3b308813..b7f4661baaa 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -1,12 +1,12 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
+import { Button, ChipInput, ChipModalTabs } from '@sim/emcn'
+import { Folder, Search, Workflow } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
-import { Button, ChipInput, ChipModalTabs } from '@/components/emcn'
-import { Folder, Search, Workflow } from '@/components/emcn/icons'
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx
index 288ccd1109f..90cf99b79c0 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx
@@ -1,12 +1,12 @@
import {
chipVariants,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
MoreHorizontal,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
export interface RowAction {
label: string
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.ts
new file mode 100644
index 00000000000..2afdf280f2c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.ts
@@ -0,0 +1,33 @@
+import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
+
+interface SaveDiscardActionsConfig {
+ dirty: boolean
+ saving: boolean
+ onSave: () => void
+ onDiscard: () => void
+ saveDisabled?: boolean
+ savingLabel?: string
+ saveLabel?: string
+}
+
+/** The dirty-gated Discard + Save action pair for settings surfaces — empty when not dirty. */
+export function saveDiscardActions({
+ dirty,
+ saving,
+ onSave,
+ onDiscard,
+ saveDisabled = false,
+ savingLabel = 'Saving...',
+ saveLabel = 'Save',
+}: SaveDiscardActionsConfig): SettingsAction[] {
+ if (!dirty) return []
+ return [
+ { text: 'Discard', onSelect: onDiscard, disabled: saving },
+ {
+ text: saving ? savingLabel : saveLabel,
+ variant: 'primary',
+ onSelect: onSave,
+ disabled: saving || saveDisabled,
+ },
+ ]
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.tsx
deleted file mode 100644
index aadef26deae..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Chip } from '@/components/emcn'
-
-interface SaveDiscardActionsProps {
- /** When false, renders nothing. */
- dirty: boolean
- /** A save is in flight — disables both chips and shows `savingLabel` on Save. */
- saving: boolean
- onSave: () => void
- onDiscard: () => void
- /** Disables Save independently of `saving` (e.g. validation errors, empty required field). */
- saveDisabled?: boolean
- savingLabel?: string
- saveLabel?: string
-}
-
-/**
- * The canonical dirty-gated Discard + Save chip pair for settings surfaces.
- * Renders nothing when not `dirty`; otherwise a fragment (no wrapper) so it
- * composes beside sibling chips in a `SettingsPanel` actions slot or a detail
- * header bar (e.g. group-detail's Delete, data-retention's Remove override).
- */
-export function SaveDiscardActions({
- dirty,
- saving,
- onSave,
- onDiscard,
- saveDisabled = false,
- savingLabel = 'Saving...',
- saveLabel = 'Save',
-}: SaveDiscardActionsProps) {
- if (!dirty) return null
- return (
- <>
-
- Discard
-
-
- {saving ? savingLabel : saveLabel}
-
- >
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx
index be6b14d6f9e..9c5e61c8df1 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx
@@ -2,7 +2,7 @@
import type { ComponentProps, CSSProperties } from 'react'
import { useState } from 'react'
-import { ChipInput } from '@/components/emcn'
+import { ChipInput } from '@sim/emcn'
const BULLET = '\u2022'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
index 8b8a6432906..84b226c14ea 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
@@ -1,12 +1,11 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { ChipInput, cn, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter } from 'next/navigation'
-import { Chip, ChipInput, Tooltip, toast } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import {
clearPendingCredentialCreateRequest,
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
@@ -18,6 +17,7 @@ import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/cr
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { isValidEnvVarName } from '@/executor/constants'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
@@ -943,33 +943,27 @@ export function SecretsManager() {
onChange: setSearchTerm,
placeholder: 'Search secrets...',
}}
- actions={
- <>
- {hasChanges && (
-
- Discard
-
- )}
- {hasConflicts || hasInvalidKeys ? (
-
-
-
- Save
-
-
- {hasConflicts ? (
- Resolve all conflicts before saving
- ) : (
- Fix invalid variable names before saving
- )}
-
- ) : (
-
- {isListSaving ? 'Saving...' : 'Save'}
-
- )}
- >
- }
+ actions={[
+ ...(hasChanges
+ ? [
+ {
+ text: 'Discard',
+ onSelect: handleCancel,
+ disabled: isListSaving,
+ } satisfies SettingsAction,
+ ]
+ : []),
+ {
+ text: isListSaving ? 'Saving...' : 'Save',
+ onSelect: handleSave,
+ disabled: hasConflicts || hasInvalidKeys || isLoading || !hasChanges || isListSaving,
+ tooltip: hasConflicts
+ ? 'Resolve all conflicts before saving'
+ : hasInvalidKeys
+ ? 'Fix invalid variable names before saving'
+ : undefined,
+ },
+ ]}
>
{!isLoading && (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts
index 7a4fca7c948..394aa641cab 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value.ts
@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
+import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { toast } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import type { WorkspaceCredential } from '@/hooks/queries/credentials'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx
index ab13443d66c..c63457a65d6 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
interface SettingsEmptyStateProps {
children: ReactNode
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx
new file mode 100644
index 00000000000..52d3a19cbd7
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx
@@ -0,0 +1,219 @@
+'use client'
+
+import {
+ type ComponentType,
+ createContext,
+ type ReactNode,
+ type Ref,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { Chip, ChipInput, ChipLink, Search, Tooltip } from '@sim/emcn'
+
+/** `useLayoutEffect` on the client (flush header changes before paint), `useEffect` during SSR. */
+const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
+
+/** The strict contract for a settings header action — rendered as a {@link Chip}, data only. */
+export interface SettingsAction {
+ text: string
+ icon?: ComponentType<{ className?: string }>
+ variant?: 'primary' | 'destructive'
+ active?: boolean
+ onSelect: () => void
+ disabled?: boolean
+ /** Hover/focus tooltip (e.g. why the action is disabled) — the shell renders it; no per-page JSX. */
+ tooltip?: string
+ /** Warm a lazy resource on hover/focus (e.g. prefetch the upgrade flow). */
+ onPrefetch?: () => void
+}
+
+export interface SettingsHeaderSearch {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ disabled?: boolean
+}
+
+/** Left-aligned back chip for a detail sub-view, returning to the section's list. */
+export interface SettingsBackAction {
+ text: string
+ icon?: ComponentType<{ className?: string }>
+ onSelect: () => void
+}
+
+export interface SettingsHeaderConfig {
+ title?: string
+ description?: string
+ docsLink?: string
+ back?: SettingsBackAction
+ actions?: SettingsAction[]
+ search?: SettingsHeaderSearch
+ /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
+ scrollContainerRef?: Ref
+}
+
+const EMPTY_CONFIG: SettingsHeaderConfig = {}
+
+const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null)
+
+interface ReadContextValue {
+ configRef: { current: SettingsHeaderConfig }
+ signature: string
+}
+
+const ReadContext = createContext(null)
+
+/**
+ * Serializes only the visible/structural fields — callbacks stay in the ref and
+ * the shell dereferences them at call time, so registering never loops and never
+ * serves a stale handler. `scrollContainerRef` is intentionally excluded (refs are
+ * identity-stable and ride along with the first content-bearing register). Any new
+ * VISIBLE field must be added here too.
+ */
+function computeSignature(c: SettingsHeaderConfig): string {
+ return JSON.stringify({
+ t: c.title ?? '',
+ d: c.description ?? '',
+ k: c.docsLink ?? '',
+ b: c.back ? [c.back.text, c.back.icon ? 1 : 0] : null,
+ a: c.actions?.map((x) => [
+ x.text,
+ x.variant ?? '',
+ x.active ?? false,
+ x.disabled ?? false,
+ x.icon ? 1 : 0,
+ x.tooltip ?? '',
+ x.onPrefetch ? 1 : 0,
+ ]),
+ s: c.search ? [c.search.value, c.search.placeholder ?? '', c.search.disabled ?? false] : null,
+ })
+}
+
+export function SettingsHeaderProvider({ children }: { children: ReactNode }) {
+ const configRef = useRef(EMPTY_CONFIG)
+ const [signature, setSignature] = useState('')
+
+ const register = useCallback((config: SettingsHeaderConfig) => {
+ configRef.current = config
+ const next = computeSignature(config)
+ setSignature((prev) => (prev === next ? prev : next))
+ }, [])
+
+ const readValue = useMemo(() => ({ configRef, signature }), [signature])
+
+ return (
+
+ {children}
+
+ )
+}
+
+/** Registers a section's header content into the persistent settings chrome. */
+export function useSettingsHeader(config: SettingsHeaderConfig) {
+ const register = useContext(RegisterContext)
+
+ useIsomorphicLayoutEffect(() => {
+ register?.(config)
+ })
+
+ useIsomorphicLayoutEffect(() => {
+ return () => register?.(EMPTY_CONFIG)
+ }, [register])
+}
+
+/**
+ * The single owner of settings page chrome: the header bar (back chip, Docs link,
+ * action chips), the scroll region, and the centered column led by the title +
+ * description, then search and `{children}`.
+ */
+export function SettingsHeaderShell({ children }: { children: ReactNode }) {
+ const read = useContext(ReadContext)
+ const configRef = read?.configRef
+ const config = configRef?.current ?? EMPTY_CONFIG
+ const { title, description, docsLink, back, actions, search, scrollContainerRef } = config
+
+ return (
+
+
+ {back ? (
+
configRef?.current.back?.onSelect()}>
+ {back.text}
+
+ ) : (
+
+ )}
+
+ {docsLink && (
+
+ Docs
+
+ )}
+ {actions?.map((action, index) => {
+ const chip = (
+ configRef?.current.actions?.[index]?.onSelect()}
+ onMouseEnter={
+ action.onPrefetch
+ ? () => configRef?.current.actions?.[index]?.onPrefetch?.()
+ : undefined
+ }
+ onFocus={
+ action.onPrefetch
+ ? () => configRef?.current.actions?.[index]?.onPrefetch?.()
+ : undefined
+ }
+ disabled={action.disabled}
+ >
+ {action.text}
+
+ )
+ return action.tooltip ? (
+
+
+ {chip}
+
+ {action.tooltip}
+
+ ) : (
+ chip
+ )
+ })}
+
+
+
+
+ {(title || description) && (
+
+ {title &&
{title} }
+ {description &&
{description}
}
+
+ )}
+ {search && (
+
configRef?.current.search?.onChange(event.target.value)}
+ disabled={search.disabled}
+ autoComplete='off'
+ className='w-full'
+ />
+ )}
+ {children}
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
index 64a22a15e05..9ec6ea696c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx
@@ -1,8 +1,12 @@
'use client'
-import { createContext, type ReactNode, useContext } from 'react'
-import { ChipInput, ChipLink, Search } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+import { createContext, type ReactNode, type Ref, useContext } from 'react'
+import {
+ type SettingsAction,
+ type SettingsBackAction,
+ type SettingsHeaderSearch,
+ useSettingsHeader,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import {
getSettingsSectionMeta,
type SettingsSection,
@@ -31,105 +35,59 @@ function useSettingsSection(): SettingsSection | null {
return useContext(SettingsSectionContext)
}
-interface SettingsPanelSearch {
- value: string
- onChange: (value: string) => void
- placeholder?: string
- disabled?: boolean
-}
-
interface SettingsPanelProps {
/** Body content rendered below the header in the centered content column. */
- children: ReactNode
- /** Right-aligned controls in the fixed header bar (e.g. a Create/Invite chip). */
- actions?: ReactNode
+ children?: ReactNode
+ /** Strict top-right action chips — data only (`text`/`icon`/`variant`/…), never JSX. */
+ actions?: SettingsAction[]
+ /** Left-aligned back chip for a detail sub-view; omit on list/panel pages. */
+ back?: SettingsBackAction
+ /** Renders the canonical search field directly below the title. */
+ search?: SettingsHeaderSearch
/** Overrides the nav-driven title (e.g. for a detail sub-view). */
title?: string
- /** Overrides the nav-driven description. */
- description?: string
- /** Overrides the nav-driven docs link (the "Docs" link rendered on the title row). */
- docsLink?: string
- /** Extra classes for the content column (layout/spacing only, e.g. a tighter `gap-*`). */
- contentClassName?: string
- /** Ref forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
- scrollContainerRef?: React.Ref
/**
- * Renders the canonical search field directly below the title. Omit on pages
- * with no search, or that pair search with extra controls (render that row in
- * `children` instead).
+ * Overrides the nav-driven description. When `title` is set (a detail sub-view),
+ * the description is used verbatim — it never falls back to the section's meta
+ * blurb, so an entity with no description renders no description.
*/
- search?: SettingsPanelSearch
+ description?: string
+ /** Overrides the nav-driven docs link (the "Docs" link rendered in the header bar). */
+ docsLink?: string
+ /** Forwarded to the scroll region (e.g. for programmatic scroll-to-bottom). */
+ scrollContainerRef?: Ref
}
/**
- * Standard chrome for a settings page: a fixed header bar (right-aligned
- * `actions`), a scroll region, and a centered content column led by the page
- * title + description. The title/description come from the active section's
- * navigation metadata by default, and can be overridden for sub-views.
- *
- * Pages render only their body as `children`; they no longer hand-roll the
- * shell, header bar, or title block.
+ * Registers a settings section's header content (title, description, docs link,
+ * action chips, search) into the persistent settings layout, then renders the
+ * section body. It owns **no** chrome: the header bar, scroll region, centered
+ * column, and spacing all live in the layout's `SettingsHeaderShell` and stay
+ * mounted across section navigation. Sections supply data only — the structured
+ * `actions` contract makes it impossible to inject a `` or a padding change.
*/
export function SettingsPanel({
children,
actions,
+ back,
+ search,
title,
description,
docsLink,
- contentClassName,
scrollContainerRef,
- search,
}: SettingsPanelProps) {
const section = useSettingsSection()
const meta = section ? getSettingsSectionMeta(section) : null
- const resolvedTitle = title ?? meta?.label
- const resolvedDescription = description ?? meta?.description
- const resolvedDocsLink = docsLink ?? meta?.docsLink
- return (
-
-
-
-
- {resolvedDocsLink && (
-
- Docs
-
- )}
- {actions}
-
-
-
-
- {(resolvedTitle || resolvedDescription) && (
-
- {resolvedTitle && (
-
{resolvedTitle}
- )}
- {resolvedDescription && (
-
{resolvedDescription}
- )}
-
- )}
- {search && (
-
search.onChange(event.target.value)}
- disabled={search.disabled}
- autoComplete='off'
- className='w-full'
- />
- )}
- {children}
-
-
-
- )
+ useSettingsHeader({
+ title: title ?? meta?.label,
+ description: title !== undefined ? description : (description ?? meta?.description),
+ docsLink: docsLink ?? meta?.docsLink,
+ back,
+ actions,
+ search,
+ scrollContainerRef,
+ })
+
+ return <>{children}>
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx
index 08b54211fa1..5a2f8f09b7e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx
@@ -1,7 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import { getErrorMessage } from '@sim/utils/errors'
import {
ChipModal,
ChipModalBody,
@@ -10,7 +9,8 @@ import {
ChipModalFooter,
ChipModalHeader,
Info,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
import {
useOrganizationMemberUsageLimit,
useUpdateOrganizationMemberUsageLimit,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx
index 32b7f6eb2be..f51fe878a99 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx
@@ -8,7 +8,7 @@ import {
ChipModalFooter,
ChipModalHeader,
Label,
-} from '@/components/emcn'
+} from '@sim/emcn'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
interface NoOrganizationViewProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx
index 50cd2d2d5b8..79c64a81d0b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx
@@ -1,7 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
import {
ChipDropdown,
type ChipDropdownOption,
@@ -11,8 +10,10 @@ import {
ChipModalFooter,
ChipModalHeader,
toast,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
import { useSession } from '@/lib/auth/auth-client'
+import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useInviteMember } from '@/hooks/queries/organization'
@@ -82,6 +83,10 @@ export function OrganizationInviteModal({
const validateEmail = useCallback(
(email: string): string | null => {
+ const formatResult = quickValidateEmail(email)
+ if (!formatResult.isValid) {
+ return formatResult.reason ?? 'Invalid email'
+ }
if (session?.user?.email && session.user.email.toLowerCase() === email) {
return 'You cannot invite yourself'
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
index 33d2cf2efb5..1ae42a9543f 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
@@ -1,10 +1,10 @@
'use client'
import { useMemo, useState } from 'react'
+import { ChipDropdown, ChipInput, Search, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
-import { ChipDropdown, ChipInput, Search, toast } from '@/components/emcn'
import {
type OrgRole,
type PermissionType,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx
index a2aaa7127ac..23c8860e883 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx
@@ -1,4 +1,4 @@
-import { ChipConfirmModal } from '@/components/emcn'
+import { ChipConfirmModal } from '@sim/emcn'
interface RemoveMemberDialogProps {
open: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
index 86e3c0f0d4f..0d371d78fff 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx
@@ -1,7 +1,6 @@
+import { Badge, Chip, cn } from '@sim/emcn'
import { useParams, useRouter } from 'next/navigation'
-import { Badge, Chip } from '@/components/emcn'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
-import { cn } from '@/lib/core/utils/cn'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
type Subscription = {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
index e6eb903c449..6c486442f2d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx
@@ -9,10 +9,10 @@ import {
Banner,
ChipConfirmModal,
ChipInput,
+ cn,
Search,
Skeleton,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
import { getUserColor } from '@/lib/workspaces/colors'
import type { RosterMember } from '@/hooks/queries/organization'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
index a6c1b6f6e5b..60776f5da79 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
@@ -1,8 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Plus } from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { Chip, Plus } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -313,17 +313,16 @@ export function TeamManagement() {
return (
<>
setInviteModalOpen(true)}
- disabled={isInvitationsDisabled}
- title={isInvitationsDisabled ? 'Invitations are disabled' : undefined}
- >
- Invite
-
- }
+ actions={[
+ {
+ text: 'Invite',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: () => setInviteModalOpen(true),
+ disabled: isInvitationsDisabled,
+ tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
+ },
+ ]}
>
- Invite
-
- }
+ actions={[
+ {
+ text: 'Invite',
+ icon: Plus,
+ variant: 'primary',
+ onSelect: handleInvite,
+ tooltip: inviteDisabledReason ?? undefined,
+ onPrefetch: isInvitationsDisabled ? prefetchUpgrade : undefined,
+ },
+ ]}
>
-
-
- MCP Servers
-
-
-
- )
+ return
}
if (error || !data) {
return (
-
-
-
- MCP Servers
-
-
+
Failed to load server details
-
+
)
}
const { server } = data
- const detailHeaderJsx = (
-
-
- MCP Servers
-
-
-
Edit Server
- {showAddDisabledTooltip ? (
-
-
-
-
- Add Workflows
-
-
-
-
- All deployed workflows have been added to this server.
-
-
- ) : (
-
setShowAddWorkflow(true)}
- disabled={!canAddWorkflow}
- >
- Add Workflows
-
- )}
-
-
- )
-
return (
<>
-
- {detailHeaderJsx}
-
-
-
-
setActiveServerTab(value as 'workflows' | 'details')}
- />
-
-
- {activeServerTab === 'workflows' && (
-
-
- Workflows
-
+
setShowAddWorkflow(true),
+ disabled: !canAddWorkflow,
+ tooltip: showAddDisabledTooltip
+ ? 'All deployed workflows have been added to this server.'
+ : undefined,
+ },
+ ]}
+ >
+
+
setActiveServerTab(value as 'workflows' | 'details')}
+ />
- {tools.length === 0 ? (
-
- No workflows added yet. Click "Add Workflow" to add a deployed
- workflow.
-
- ) : (
-
- {tools.map((tool) => (
-
-
-
- {tool.toolName}
-
-
- {tool.toolDescription || 'No description'}
-
-
-
- setToolToView(tool) },
- {
- label: 'Remove',
- destructive: true,
- disabled: deleteToolMutation.isPending,
- onSelect: () => setToolToDelete(tool),
- },
- ]}
- />
-
-
- ))}
+
+ {activeServerTab === 'workflows' && (
+
+
Workflows
+
+ {tools.length === 0 ? (
+
+ No workflows added yet. Click "Add Workflow" to add a deployed
+ workflow.
+
+ ) : (
+
+ {tools.map((tool) => (
+
+
+
+ {tool.toolName}
+
+
+ {tool.toolDescription || 'No description'}
+
+
+
+ setToolToView(tool) },
+ {
+ label: 'Remove',
+ destructive: true,
+ disabled: deleteToolMutation.isPending,
+ onSelect: () => setToolToDelete(tool),
+ },
+ ]}
+ />
+
- )}
+ ))}
+
+ )}
- {deployedWorkflows.length === 0 && !isLoadingWorkflows && (
-
- Deploy a workflow first to add it to this server.
-
- )}
+ {deployedWorkflows.length === 0 && !isLoadingWorkflows && (
+
+ Deploy a workflow first to add it to this server.
+
+ )}
+
+ )}
+
+ {activeServerTab === 'details' && (
+
+
+
+
+ Server Name
+
+
{server.name}
+
+
+
+ Transport
+
+
Streamable-HTTP
+
+
+
Access
+
+ {server.isPublic ? 'Public' : 'API Key'}
+
+
+
+
+ {server.description?.trim() && (
+
+
+ Description
+
+
{server.description}
)}
- {activeServerTab === 'details' && (
-
-
-
-
- Server Name
-
-
{server.name}
-
-
-
- Transport
-
-
Streamable-HTTP
-
-
-
- Access
-
-
- {server.isPublic ? 'Public' : 'API Key'}
-
-
-
+
- {server.description?.trim() && (
-
-
- Description
-
-
- {server.description}
-
-
- )}
+
+
+
+ MCP Client
+
+
+
setActiveConfigTab(v as McpClientType)}
+ >
+ Cursor
+ Claude Code
+ Claude Desktop
+ VS Code
+ Sim
+
+
-
-
URL
-
- {mcpServerUrl}
+ {activeConfigTab === 'sim' ? (
+
+
+
+ Add this MCP server to your workspace so you can use its tools in other
+ workflows via the MCP block.
+
{
+ try {
+ const headers: Record = server.isPublic
+ ? {}
+ : { 'X-API-Key': '{{SIM_API_KEY}}' }
+ await addToWorkspaceMutation.mutateAsync({
+ workspaceId,
+ config: {
+ name: server.name,
+ transport: 'streamable-http',
+ url: mcpServerUrl,
+ timeout: 30000,
+ headers,
+ enabled: true,
+ },
+ })
+ setAddedToWorkspace(true)
+ addedToWorkspaceTimerRef.current = setTimeout(
+ () => setAddedToWorkspace(false),
+ 3000
+ )
+ } catch (err) {
+ logger.error('Failed to add server to workspace:', err)
+ }
+ }}
+ >
+ {addToWorkspaceMutation.isPending ? (
+ 'Adding...'
+ ) : addedToWorkspace ? (
+ <>
+
+ Added to Workspace
+ >
+ ) : (
+ <>
+
+ Add to Workspace
+ >
+ )}
+
+ {addToWorkspaceMutation.isError && (
+
+ {addToWorkspaceMutation.error?.message || 'Failed to add server'}
+
+ )}
-
-
-
-
- MCP Client
-
-
-
setActiveConfigTab(v as McpClientType)}
+
+ ) : (
+
+
+
+ Configuration
+
+ handleCopyConfig(server.isPublic, server.name)}
+ className='!p-1.5 -my-1.5'
>
- Cursor
- Claude Code
- Claude Desktop
- VS Code
- Sim
-
+ {copiedConfig ? (
+
+ ) : (
+
+ )}
+
-
- {activeConfigTab === 'sim' ? (
-
-
-
- Add this MCP server to your workspace so you can use its tools in other
- workflows via the MCP block.
-
-
{
- try {
- const headers: Record = server.isPublic
- ? {}
- : { 'X-API-Key': '{{SIM_API_KEY}}' }
- await addToWorkspaceMutation.mutateAsync({
- workspaceId,
- config: {
- name: server.name,
- transport: 'streamable-http',
- url: mcpServerUrl,
- timeout: 30000,
- headers,
- enabled: true,
- },
- })
- setAddedToWorkspace(true)
- addedToWorkspaceTimerRef.current = setTimeout(
- () => setAddedToWorkspace(false),
- 3000
- )
- } catch (err) {
- logger.error('Failed to add server to workspace:', err)
- }
- }}
- >
- {addToWorkspaceMutation.isPending ? (
- 'Adding...'
- ) : addedToWorkspace ? (
- <>
-
- Added to Workspace
- >
- ) : (
- <>
-
- Add to Workspace
- >
- )}
-
- {addToWorkspaceMutation.isError && (
-
- {addToWorkspaceMutation.error?.message || 'Failed to add server'}
-
- )}
-
-
- ) : (
-
-
-
- Configuration
-
- handleCopyConfig(server.isPublic, server.name)}
- className='!p-1.5 -my-1.5'
- >
- {copiedConfig ? (
-
- ) : (
-
- )}
-
-
-
- {!server.isPublic && (
-
- Replace $SIM_API_KEY with your API key, or{' '}
- setShowCreateApiKeyModal(true)}
- className='underline hover-hover:text-[var(--text-secondary)]'
- >
- create one now
-
-
- )}
-
+
+ )}
+
+ {!server.isPublic && (
+
+ Replace $SIM_API_KEY with your API key, or{' '}
+ setShowCreateApiKeyModal(true)}
+ className='underline hover-hover:text-[var(--text-secondary)]'
+ >
+ create one now
+
+
)}
)}
-
+ )}
-
+
setShowAddModal(true),
+ disabled: isLoading,
+ },
+ ]
+
return (
<>
setShowAddModal(true)}
- disabled={isLoading}
- >
- Add Server
-
- }
+ actions={actions}
>
{error ? (
@@ -993,7 +949,7 @@ export function WorkflowMcpServers() {
) : isLoading ? null : !hasServers ? (
- Click "Add Server" above to get started
+ Click "Add server" above to get started
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
index 267741c1746..d2d9ff3e7b9 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
@@ -18,7 +18,7 @@ import {
User,
Users,
Wrench,
-} from '@/components/emcn'
+} from '@sim/emcn'
import { McpIcon } from '@/components/icons'
import { getEnv, isTruthy } from '@/lib/core/config/env'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
index e55da113389..7aca00a8c7f 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
@@ -1,8 +1,8 @@
'use client'
import { useState } from 'react'
-import { Chip, ChipCopyInput, ChipLink, Send } from '@/components/emcn'
-import { ArrowLeft, Key } from '@/components/emcn/icons'
+import { Chip, ChipCopyInput, ChipLink, Send } from '@sim/emcn'
+import { ArrowLeft, Key } from '@sim/emcn/icons'
import {
AddPeopleModal,
CredentialDetailHeading,
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx
index 6f3a332db51..a49e5c2280a 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx
@@ -1,8 +1,8 @@
'use client'
import { useCallback, useState } from 'react'
+import { Chip, ChipInput, ChipModalField, Loader } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
-import { Chip, ChipInput, ChipModalField, Loader } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { importSkillContract } from '@/lib/api/contracts'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx
index c7d8b6ce8a7..a8b6e2fb4dd 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useState } from 'react'
-import dynamic from 'next/dynamic'
-import { useParams } from 'next/navigation'
import {
ChipModal,
ChipModalBody,
@@ -12,8 +10,10 @@ import {
ChipModalHeader,
ChipModalTabs,
chipFieldSurfaceClass,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+ cn,
+} from '@sim/emcn'
+import dynamic from 'next/dynamic'
+import { useParams } from 'next/navigation'
import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import'
import { parseSkillMarkdown } from '@/app/workspace/[workspaceId]/skills/components/utils'
import type { SkillDefinition } from '@/hooks/queries/skills'
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
index 110de117857..182cc48af45 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
@@ -1,12 +1,12 @@
'use client'
import { useState } from 'react'
+import { Chip, ChipConfirmModal, ChipInput, Search } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
-import { Chip, ChipConfirmModal, ChipInput, Search } from '@/components/emcn'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx
index 003680668d0..0f21afa12cc 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx
@@ -1,19 +1,10 @@
'use client'
import { useState } from 'react'
+import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast } from '@sim/emcn'
+import { X } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
-import {
- Button,
- ChipCombobox,
- ChipInput,
- FieldDivider,
- Label,
- Switch,
- toast,
-} from '@/components/emcn'
-import { X } from '@/components/emcn/icons'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
-import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
import {
FieldError,
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts
index 6c9f31ade67..9b4a7af4790 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts
@@ -6,7 +6,7 @@ import {
TypeJson,
TypeNumber,
TypeText,
-} from '@/components/emcn/icons'
+} from '@sim/emcn/icons'
import type { ColumnDefinition } from '@/lib/table'
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx
index de00999f35d..997d1943627 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx
@@ -4,7 +4,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from '@/components/emcn'
+} from '@sim/emcn'
import {
ArrowDown,
ArrowUp,
@@ -15,7 +15,7 @@ import {
RefreshCw,
Square,
Trash,
-} from '@/components/emcn/icons'
+} from '@sim/emcn/icons'
import type { ContextMenuState } from '../../types'
interface ContextMenuProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx
index 559d8858cbf..e964a276987 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx
@@ -1,9 +1,8 @@
'use client'
import { useEffect, useState } from 'react'
+import { Badge, Button, ChipModalTabs, cn, X } from '@sim/emcn'
import { formatDuration } from '@sim/utils/formatting'
-import { Badge, Button, ChipModalTabs, X } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table'
import {
adjustBgForContrast,
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx
index ea0ffdb43d4..d7ace26b0ef 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx
@@ -1,8 +1,6 @@
'use client'
import { useState } from 'react'
-import { toError } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
import {
Badge,
Button,
@@ -13,8 +11,10 @@ import {
Label,
Switch,
toast,
-} from '@/components/emcn'
-import { ArrowLeft, X } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { ArrowLeft, X } from '@sim/emcn/icons'
+import { toError } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables'
import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table'
import { deriveOutputColumnName } from '@/lib/table/column-naming'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx
index 6b50989f05f..eb6661bbe7e 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx
@@ -1,9 +1,8 @@
'use client'
import { useState } from 'react'
-import { Button, ChipInput } from '@/components/emcn'
-import { Search, X } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+import { Button, ChipInput, cn } from '@sim/emcn'
+import { Search, X } from '@sim/emcn/icons'
import type { ColumnDefinition, WorkflowGroup } from '@/lib/table'
import { ALL_ENRICHMENTS } from '@/enrichments'
import { getEnrichment } from '@/enrichments/registry'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx
index 3e85dade1bc..2886aad2921 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx
@@ -1,6 +1,5 @@
'use client'
-import { Sparkles } from 'lucide-react'
import {
ChipChevronDown,
chipContentIconClass,
@@ -12,7 +11,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
Plus,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { Sparkles } from 'lucide-react'
import type { ColumnDefinition } from '@/lib/table'
import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
index 0aee6946a8a..d0045f4f40a 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx
@@ -1,9 +1,6 @@
'use client'
import { useId, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { useParams } from 'next/navigation'
import {
Checkbox,
ChipConfirmModal,
@@ -15,7 +12,10 @@ import {
ChipModalFooter,
ChipModalHeader,
Label,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { useParams } from 'next/navigation'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
import { cleanCellValue, formatValueForInput } from '../../utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx
index 9283959ffdc..3ef2db128c7 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control/run-status-control.tsx
@@ -1,8 +1,8 @@
'use client'
import { memo } from 'react'
-import { Button } from '@/components/emcn'
-import { Loader, Square } from '@/components/emcn/icons'
+import { Button } from '@sim/emcn'
+import { Loader, Square } from '@sim/emcn/icons'
interface RunStatusControlProps {
running: number
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx
index d08ad20bbfc..02871d4820e 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx
@@ -1,7 +1,7 @@
'use client'
import type React from 'react'
-import { Label } from '@/components/emcn'
+import { Label } from '@sim/emcn'
/**
* Field label with a trailing required marker, matching the sidebar field
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx
index eff97d3e890..301658acb1e 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx
@@ -1,10 +1,9 @@
'use client'
import type React from 'react'
+import { Button, cn, Tooltip } from '@sim/emcn'
+import { Eye, PlayOutline, RefreshCw, Square } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
-import { Button, Tooltip } from '@/components/emcn'
-import { Eye, PlayOutline, RefreshCw, Square } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
interface TableActionBarProps {
/** Number of (row × group) cells the run/stop buttons would target. Drives
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
index 9d9800bffe3..4073f24e098 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx
@@ -1,9 +1,9 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
+import { Button, ChipDropdown, ChipInput } from '@sim/emcn'
+import { Plus, X } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
-import { Button, ChipDropdown, ChipInput } from '@/components/emcn'
-import { Plus, X } from '@/components/emcn/icons'
import type { ColumnDefinition, Filter, FilterRule } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
index c4c7904a711..b5bdccd21db 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
@@ -2,9 +2,8 @@
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
+import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
import { parse } from 'tldts'
-import { Badge, Checkbox, Tooltip } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import type { RowExecutionMetadata } from '@/lib/table'
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
import { storageToDisplay } from '../../../utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx
index d610d99bc60..4e51cef0a82 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx
@@ -2,7 +2,7 @@
import type React from 'react'
import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { Button } from '@/components/emcn'
+import { Button } from '@sim/emcn'
import type { TableRow as TableRowType } from '@/lib/table'
import type { EditingCell, SaveReason } from '../../../types'
import { cleanCellValue, displayToStorage, formatValueForInput } from '../../../utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
index 01318637e15..77f5521a8b7 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx
@@ -1,9 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
-import { Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
-import { Calendar } from '@/components/emcn/components/calendar/calendar'
-import { cn } from '@/lib/core/utils/cn'
+import { Calendar, cn, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
import type { ColumnDefinition } from '@/lib/table'
import type { SaveReason } from '../../../types'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell.tsx
index 00950b6f5fe..fdbd2776e97 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell.tsx
@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
index 82114fc3985..b73bf68dc49 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
@@ -1,11 +1,9 @@
'use client'
import React from 'react'
-import { Button, Checkbox } from '@/components/emcn'
-import { PlayOutline, Square } from '@/components/emcn/icons'
+import { Button, Checkbox, cn, handleKeyboardActivation } from '@sim/emcn'
+import { PlayOutline, Square } from '@sim/emcn/icons'
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import type { TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import { getUnmetGroupDeps } from '@/lib/table/deps'
import type { SaveReason } from '../../types'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx
index 223dddeb6e6..8d775e8b8ac 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx
@@ -1,8 +1,8 @@
'use client'
import React, { useCallback, useEffect, useRef, useState } from 'react'
-import { ChevronDown } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
+import { ChevronDown } from '@sim/emcn/icons'
import type { WorkflowGroup } from '@/lib/table'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { COL_WIDTH, SELECTION_TINT_BG } from '../constants'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx
index d8c7bbded1e..a1d747c463d 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx
@@ -1,7 +1,7 @@
'use client'
import type React from 'react'
-import { Tooltip } from '@/components/emcn'
+import { Tooltip } from '@sim/emcn'
import {
Calendar as CalendarIcon,
PlayOutline,
@@ -10,7 +10,7 @@ import {
TypeNumber,
TypeText,
WorkflowX,
-} from '@/components/emcn/icons'
+} from '@sim/emcn/icons'
import type { BlockIconInfo } from '../types'
export const COLUMN_TYPE_ICONS: Record
= {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx
index 321ea035292..0f760fff8e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx
@@ -3,6 +3,7 @@
import type React from 'react'
import { useRef, useState } from 'react'
import {
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -11,7 +12,7 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
-} from '@/components/emcn'
+} from '@sim/emcn'
import {
ArrowLeft,
ArrowRight,
@@ -23,9 +24,8 @@ import {
PlayOutline,
Trash,
Workflow,
-} from '@/components/emcn/icons'
+} from '@sim/emcn/icons'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
-import { cn } from '@/lib/core/utils/cn'
import type { WorkflowGroupType } from '@/lib/table'
import { getEnrichment } from '@/enrichments/registry'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx
index 4e8b124c6d6..d8d176406a8 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx
@@ -1,9 +1,9 @@
'use client'
import type React from 'react'
+import { Button, ChipInput } from '@sim/emcn'
+import { Loader, X } from '@sim/emcn/icons'
import { ChevronDown, ChevronUp } from 'lucide-react'
-import { Button, ChipInput } from '@/components/emcn'
-import { Loader, X } from '@/components/emcn/icons'
export interface TableFindProps {
query: string
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx
index 1dc09fdfd51..7521df83ecb 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx
@@ -2,14 +2,13 @@
import type React from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { cn, toast, useToast } from '@sim/emcn'
+import { Loader, TableX } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
-import { toast, useToast } from '@/components/emcn'
-import { Loader, TableX } from '@/components/emcn/icons'
import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables'
-import { cn } from '@/lib/core/utils/cn'
import { captureEvent } from '@/lib/posthog/client'
import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx
index 79730174d9e..068923eaafa 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx
@@ -1,9 +1,8 @@
'use client'
import React from 'react'
-import { Button, Checkbox } from '@/components/emcn'
-import { Plus } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+import { Button, Checkbox, cn } from '@sim/emcn'
+import { Plus } from '@sim/emcn/icons'
import { ADD_COL_WIDTH, CELL_HEADER_CHECKBOX, COL_WIDTH } from './constants'
import type { DisplayColumn } from './types'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx
index 94af5b3cda2..06dd6bb98bc 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { Badge, ChipCombobox, CollapsibleCard, Label } from '@/components/emcn'
+import { Badge, ChipCombobox, CollapsibleCard, Label } from '@sim/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import type { InputFormatField } from '@/lib/workflows/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx
index 05322b64836..8390396c1fe 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx
@@ -1,6 +1,6 @@
'use client'
-import { ChipCombobox, Label } from '@/components/emcn'
+import { ChipCombobox, Label } from '@sim/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx
index 48038f3e1db..0143ae0c062 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx
@@ -2,10 +2,6 @@
import type React from 'react'
import { useMemo, useState } from 'react'
-import { toError } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
-import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { ExternalLink, RepeatIcon, SplitIcon } from 'lucide-react'
import {
Button,
ButtonGroup,
@@ -13,6 +9,7 @@ import {
ChipCombobox,
ChipInput,
type ComboboxOptionGroup,
+ cn,
DashedDividerLine,
FieldDivider,
Label,
@@ -20,8 +17,12 @@ import {
Switch,
Tooltip,
toast,
-} from '@/components/emcn'
-import { ArrowLeft, ChevronDown, X } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { ArrowLeft, ChevronDown, X } from '@sim/emcn/icons'
+import { toError } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { ExternalLink, RepeatIcon, SplitIcon } from 'lucide-react'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import type {
@@ -32,7 +33,6 @@ import {
putWorkflowNormalizedStateContract,
type WorkflowStateContractInput,
} from '@/lib/api/contracts/workflows'
-import { cn } from '@/lib/core/utils/cn'
import type {
ColumnDefinition,
WorkflowGroup,
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx
index ea8d04d530b..d17ae78e43d 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx
@@ -1,8 +1,8 @@
'use client'
+import { Button } from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
import { useParams, useRouter } from 'next/navigation'
-import { Button } from '@/components/emcn'
-import { ArrowLeft } from '@/components/emcn/icons'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function TableError({ error, reset }: ErrorBoundaryProps) {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts
index 6a0efe9695d..45200624ff8 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts
@@ -1,9 +1,9 @@
'use client'
import { useEffect, useRef } from 'react'
+import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
-import { toast } from '@/components/emcn'
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
import type { RowData, RowExecutionMetadata, RowExecutions, TableDefinition } from '@/lib/table'
import { isExecInFlight } from '@/lib/table/deps'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/loading.tsx
index 2d07d4e540c..ac349574e79 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/loading.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Table as TableIcon } from '@/components/emcn/icons'
+import { Table as TableIcon } from '@sim/emcn/icons'
import {
type BreadcrumbItem,
ResourceChromeFallback,
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index a44d2c72678..199ec81ed82 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -1,12 +1,12 @@
'use client'
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
+import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
+import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
-import { Chip, ChipConfirmModal, toast } from '@/components/emcn'
-import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@/components/emcn/icons'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import { captureEvent } from '@/lib/posthog/client'
import type {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
index f042447a7a8..ca080f19307 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
@@ -1,8 +1,6 @@
'use client'
import { useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import {
Button,
ButtonGroup,
@@ -22,7 +20,9 @@ import {
TableHeader,
TableRow,
toast,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import'
import type { TableDefinition } from '@/lib/table/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx
index 238e3f0f7f4..46deda65b1c 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx
@@ -1,6 +1,5 @@
'use client'
-import { createLogger } from '@sim/logger'
import {
Button,
DropdownMenu,
@@ -8,8 +7,9 @@ import {
DropdownMenuTrigger,
ProgressItem,
toast,
-} from '@/components/emcn'
-import { CircleAlert, CircleCheck, Loader } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { CircleAlert, CircleCheck, Loader } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
import { getImportStage } from './import-stage'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts
index e5227ab2665..a89483fc9e7 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts
@@ -1,8 +1,8 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
+import { toast } from '@sim/emcn'
import { useShallow } from 'zustand/react/shallow'
-import { toast } from '@/components/emcn'
import { useTablesList, useWorkspaceExportJobs } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx
index f8de937720e..b8064ef6627 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx
@@ -7,8 +7,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
Upload,
-} from '@/components/emcn'
-import { Database, Download, Duplicate, Pencil, Trash } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Database, Download, Duplicate, Pencil, Trash } from '@sim/emcn/icons'
interface TableContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx
index d3fa890ad04..f0925f7cc6c 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx
@@ -6,8 +6,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
Upload,
-} from '@/components/emcn'
-import { Plus } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Plus } from '@sim/emcn/icons'
interface TablesListContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx b/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx
index 2af5787728c..b6e01e9d0aa 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/loading.tsx
@@ -1,7 +1,7 @@
'use client'
-import { Plus, Upload } from '@/components/emcn'
-import { Table as TableIcon } from '@/components/emcn/icons'
+import { Plus, Upload } from '@sim/emcn'
+import { Table as TableIcon } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index 23d00ef0a70..c88ae332b2b 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -1,13 +1,13 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { ComboboxOption } from '@sim/emcn'
+import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn'
+import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
-import type { ComboboxOption } from '@/components/emcn'
-import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@/components/emcn'
-import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
import type { TableDefinition } from '@/lib/table'
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
import type {
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle.tsx
index 32af8f71b39..148e0561c65 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle.tsx
@@ -1,6 +1,6 @@
'use client'
-import { ChipSwitch, ChipTag } from '@/components/emcn'
+import { ChipSwitch, ChipTag } from '@sim/emcn'
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-table.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-table.tsx
index f6277392811..90e060302f0 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-table.tsx
@@ -1,8 +1,6 @@
'use client'
-
-import { chipVariants } from '@/components/emcn'
+import { chipVariants, cn } from '@sim/emcn'
import { SlackIcon } from '@/components/icons'
-import { cn } from '@/lib/core/utils/cn'
import { BillingPeriodToggle } from '@/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle'
import {
type CellValue,
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx
index 872702b5ad6..808be0af4a0 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx
@@ -1,7 +1,5 @@
'use client'
-
-import { Check, ChipTag, Credit, chipVariants, Info, RefreshCw } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+import { Check, ChipTag, Credit, chipVariants, cn, Info, RefreshCw } from '@sim/emcn'
/**
* Props for {@link UpgradePlanCard}.
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts
index bf9eeafe42f..548471e86ef 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts
@@ -1,8 +1,8 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
import { getErrorMessage } from '@sim/utils/errors'
-import { toast } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
index 500a7aa4512..62261094dc3 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
@@ -1,10 +1,10 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
+import { ArrowLeft, Chip, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
-import { ArrowLeft, Chip, toast } from '@/components/emcn'
import {
getUpgradeCardCta,
type PlanCardCta,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx
index 37b9f8c145f..08bde247dbc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx
@@ -1,8 +1,7 @@
import { memo, useCallback } from 'react'
+import { Button, cn, Duplicate, PlayOutline, Tooltip, Trash2, toast } from '@sim/emcn'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
-import { Button, Duplicate, PlayOutline, Tooltip, Trash2, toast } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx
index f77a76394c7..acc8e422da7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx
@@ -1,13 +1,7 @@
'use client'
import type { RefObject } from 'react'
-import {
- Popover,
- PopoverAnchor,
- PopoverContent,
- PopoverDivider,
- PopoverItem,
-} from '@/components/emcn'
+import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx
index 3d6cb1c77df..a4cbe515e0c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx
@@ -1,13 +1,7 @@
'use client'
import type { RefObject } from 'react'
-import {
- Popover,
- PopoverAnchor,
- PopoverContent,
- PopoverDivider,
- PopoverItem,
-} from '@/components/emcn'
+import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn'
/**
* Props for CanvasMenu component
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
index fe3892ec05c..bb5a4180da8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
@@ -1,13 +1,10 @@
'use client'
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { AlertCircle, ArrowUp, MoreVertical, Paperclip, Square, X } from 'lucide-react'
-import { useShallow } from 'zustand/react/shallow'
import {
Badge,
Button,
+ cn,
Input,
Popover,
PopoverContent,
@@ -16,10 +13,13 @@ import {
PopoverTrigger,
Tooltip,
Trash,
-} from '@/components/emcn'
-import { Download } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Download } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
+import { AlertCircle, ArrowUp, MoreVertical, Paperclip, Square, X } from 'lucide-react'
+import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client'
-import { cn } from '@/lib/core/utils/cn'
import {
extractBlockIdFromOutputId,
extractPathFromOutputId,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx
index 41c5962628c..8ad659a4535 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx
@@ -2,10 +2,9 @@
import type React from 'react'
import { useMemo } from 'react'
+import { Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
-import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import {
type FlattenOutputsBlockInput,
flattenWorkflowOutputs,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx
index 6b53eab1f3b..cac027c4a73 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/command-list/command-list.tsx
@@ -1,14 +1,12 @@
'use client'
import { useCallback } from 'react'
+import { Button, cn, handleKeyboardActivation, Library } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { Search } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
-import { Button, Library } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useSearchModalStore } from '@/stores/modals/search/store'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx
index c262d9ea264..c236ee2e5b6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx
@@ -1,10 +1,10 @@
'use client'
import { Component, type ReactNode, useEffect } from 'react'
+import { Button } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { RefreshCw } from 'lucide-react'
import { ReactFlowProvider } from 'reactflow'
-import { Button } from '@/components/emcn'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx
index 2c24056ca5c..38130ca0903 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx
@@ -1,11 +1,6 @@
import { memo, useCallback, useMemo } from 'react'
+import { BLOCK_DIMENSIONS, NoteBlockView } from '@sim/workflow-renderer'
import type { NodeProps } from 'reactflow'
-import remarkBreaks from 'remark-breaks'
-import { Streamdown } from 'streamdown'
-import 'streamdown/styles.css'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
-import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -15,9 +10,7 @@ import type { WorkflowBlockProps } from '../workflow-block/types'
interface NoteBlockNodeData extends WorkflowBlockProps {}
-/**
- * Extract string value from subblock value object or primitive
- */
+/** Extracts the string content from a raw subblock value (string or `{ value }`). */
function extractFieldValue(rawValue: unknown): string | undefined {
if (typeof rawValue === 'string') return rawValue
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
@@ -27,441 +20,14 @@ function extractFieldValue(rawValue: unknown): string | undefined {
return undefined
}
-type EmbedInfo = {
- url: string
- type: 'iframe' | 'video' | 'audio'
- aspectRatio?: string
-}
-
-const EMBED_SCALE = 0.78
-const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
-
-function getTwitchParent(): string {
- return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
-}
-
/**
- * Get embed info for supported media platforms
+ * Editor container for {@link NoteBlockView}.
+ *
+ * Resolves the note's markdown content from its subblock value, the enabled/ring
+ * visual state from {@link useBlockVisual}, and edit permission, then publishes
+ * deterministic dimensions and renders the pure view shared with the docs
+ * preview — injecting the editor-only {@link ActionBar} via the `actionBar` slot.
*/
-function getEmbedInfo(url: string): EmbedInfo | null {
- const youtubeMatch = url.match(
- /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
- )
- if (youtubeMatch) {
- return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' }
- }
-
- const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
- if (vimeoMatch) {
- return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
- }
-
- const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
- if (dailymotionMatch) {
- return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
- }
-
- const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
- if (twitchVideoMatch) {
- return {
- url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
- type: 'iframe',
- }
- }
-
- const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
- if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
- return {
- url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
- type: 'iframe',
- }
- }
-
- const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
- if (streamableMatch) {
- return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
- }
-
- const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
- if (wistiaMatch) {
- return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
- }
-
- const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
- if (tiktokMatch) {
- return {
- url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
- type: 'iframe',
- aspectRatio: '9/16',
- }
- }
-
- const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
- if (soundcloudMatch) {
- return {
- url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
- type: 'iframe',
- aspectRatio: '3/2',
- }
- }
-
- const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
- if (spotifyTrackMatch) {
- return {
- url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
- type: 'iframe',
- aspectRatio: '3.7/1',
- }
- }
-
- const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
- if (spotifyAlbumMatch) {
- return {
- url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
- if (spotifyPlaylistMatch) {
- return {
- url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
- if (spotifyEpisodeMatch) {
- return {
- url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
- type: 'iframe',
- aspectRatio: '2.5/1',
- }
- }
-
- const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
- if (spotifyShowMatch) {
- return {
- url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
- type: 'iframe',
- aspectRatio: '3.7/1',
- }
- }
-
- const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
- if (appleMusicSongMatch) {
- const [, country, songId] = appleMusicSongMatch
- return {
- url: `https://embed.music.apple.com/${country}/song/${songId}`,
- type: 'iframe',
- aspectRatio: '3/2',
- }
- }
-
- const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
- if (appleMusicAlbumMatch) {
- const [, country, albumId] = appleMusicAlbumMatch
- return {
- url: `https://embed.music.apple.com/${country}/album/${albumId}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const appleMusicPlaylistMatch = url.match(
- /music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
- )
- if (appleMusicPlaylistMatch) {
- const [, country, playlistId] = appleMusicPlaylistMatch
- return {
- url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
- if (loomMatch) {
- return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
- }
-
- const facebookVideoMatch =
- url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
- if (facebookVideoMatch) {
- return {
- url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
- type: 'iframe',
- }
- }
-
- const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
- if (instagramReelMatch) {
- return {
- url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
- type: 'iframe',
- aspectRatio: '9/16',
- }
- }
-
- const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
- if (instagramPostMatch) {
- return {
- url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
- type: 'iframe',
- aspectRatio: '4/5',
- }
- }
-
- const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
- if (twitterMatch) {
- return {
- url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
- type: 'iframe',
- aspectRatio: '3/4',
- }
- }
-
- const rumbleMatch =
- url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
- if (rumbleMatch) {
- return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
- }
-
- const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
- if (bilibiliMatch) {
- return {
- url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
- type: 'iframe',
- }
- }
-
- const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
- if (vidyardMatch) {
- return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
- }
-
- const cfStreamMatch =
- url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
- url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
- if (cfStreamMatch) {
- return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
- }
-
- const twitchClipMatch =
- url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
- url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
- if (twitchClipMatch) {
- return {
- url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
- type: 'iframe',
- }
- }
-
- const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
- if (mixcloudMatch) {
- return {
- url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
- type: 'iframe',
- aspectRatio: '2/1',
- }
- }
-
- const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
- if (googleDriveMatch) {
- return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
- }
-
- if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
- const directUrl = url
- .replace('www.dropbox.com', 'dl.dropboxusercontent.com')
- .replace('?dl=0', '')
- return { url: directUrl, type: 'video' }
- }
-
- const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
- if (tenorMatch) {
- return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
- }
-
- const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
- if (giphyMatch) {
- return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
- }
-
- if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
- return { url, type: 'video' }
- }
-
- if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
- return { url, type: 'audio' }
- }
-
- return null
-}
-
-/**
- * Compact markdown renderer for note blocks with tight spacing
- */
-const NOTE_REMARK_PLUGINS = [remarkBreaks]
-
-const NOTE_COMPONENTS = {
- p: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h1: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h2: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h3: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h4: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- ul: ({ children }: { children?: React.ReactNode }) => (
-
- ),
- ol: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- li: ({ children }: { children?: React.ReactNode }) => {children} ,
- inlineCode: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- code: ({ children, className, ...props }: { children?: React.ReactNode; className?: string }) => (
-
- {children}
-
- ),
- a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
- const embedInfo = href ? getEmbedInfo(href) : null
- if (embedInfo) {
- return (
-
-
- {children}
-
-
- {embedInfo.type === 'iframe' && (
-
-
-
- )}
- {embedInfo.type === 'video' && (
-
-
-
- )}
- {embedInfo.type === 'audio' && (
-
-
-
- )}
-
-
- )
- }
- return (
-
- {children}
-
- )
- },
- strong: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- em: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- blockquote: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- table: ({ children }: { children?: React.ReactNode }) => (
-
- ),
- thead: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- tbody: ({ children }: { children?: React.ReactNode }) => {children} ,
- tr: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- th: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- td: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
-}
-
-const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
- return (
-
- {content}
-
- )
-})
-
export const NoteBlock = memo(function NoteBlock({
id,
data,
@@ -499,8 +65,8 @@ export const NoteBlock = memo(function NoteBlock({
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
/**
- * Calculate deterministic dimensions based on content structure.
- * Uses fixed width and computed height to avoid ResizeObserver jitter.
+ * Calculate deterministic dimensions based on content structure. Uses fixed
+ * width and computed height to avoid ResizeObserver jitter.
*/
useBlockDimensions({
blockId: id,
@@ -517,45 +83,14 @@ export const NoteBlock = memo(function NoteBlock({
})
return (
-
-
handleKeyboardActivation(event, handleClick)}
- >
-
-
-
-
-
-
- {isEmpty ? (
-
Add note…
- ) : (
-
- )}
-
-
- {hasRing && (
-
- )}
-
-
+ }
+ />
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts
index 57d808c1f03..4c40839d27b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts
@@ -1,10 +1,10 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { toast } from '@/components/emcn'
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx
deleted file mode 100644
index 90c3222cf4c..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx
+++ /dev/null
@@ -1,869 +0,0 @@
-'use client'
-
-import { useEffect, useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { Check, Clipboard } from 'lucide-react'
-import { useParams } from 'next/navigation'
-import {
- Button,
- ButtonGroup,
- ButtonGroupItem,
- Checkbox,
- ChipInput,
- Code,
- Input,
- Label,
- Skeleton,
- TagInput,
- Textarea,
- Tooltip,
-} from '@/components/emcn'
-import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
-import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
-import {
- useA2AAgentByWorkflow,
- useCreateA2AAgent,
- useDeleteA2AAgent,
- usePublishA2AAgent,
- useUpdateA2AAgent,
-} from '@/hooks/queries/a2a/agents'
-import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
-import { useSubBlockStore } from '@/stores/workflows/subblock/store'
-import { useWorkflowStore } from '@/stores/workflows/workflow/store'
-
-const logger = createLogger('A2ADeploy')
-
-interface InputFormatField {
- id?: string
- name?: string
- type?: string
- value?: unknown
- collapsed?: boolean
-}
-
-/**
- * Check if a description is a default/placeholder value that should be filtered out
- */
-function isDefaultDescription(desc: string | null | undefined, workflowName: string): boolean {
- if (!desc) return true
- const normalized = desc.toLowerCase().trim()
- return (
- normalized === '' ||
- normalized === 'new workflow' ||
- normalized === 'your first workflow - start building here!' ||
- normalized === workflowName.toLowerCase()
- )
-}
-
-type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript'
-
-const LANGUAGE_LABELS: Record = {
- curl: 'cURL',
- python: 'Python',
- javascript: 'JavaScript',
- typescript: 'TypeScript',
-}
-
-const LANGUAGE_SYNTAX: Record = {
- curl: 'javascript',
- python: 'python',
- javascript: 'javascript',
- typescript: 'javascript',
-}
-
-interface A2aDeployProps {
- workflowId: string
- workflowName: string
- workflowDescription?: string | null
- isDeployed: boolean
- workflowNeedsRedeployment?: boolean
- onSubmittingChange?: (submitting: boolean) => void
- onCanSaveChange?: (canSave: boolean) => void
- onNeedsRepublishChange?: (needsRepublish: boolean) => void
- onDeployWorkflow?: () => Promise
-}
-
-type AuthScheme = 'none' | 'apiKey'
-
-export function A2aDeploy({
- workflowId,
- workflowName,
- workflowDescription,
- isDeployed,
- workflowNeedsRedeployment,
- onSubmittingChange,
- onCanSaveChange,
- onNeedsRepublishChange,
- onDeployWorkflow,
-}: A2aDeployProps) {
- const params = useParams()
- const workspaceId = params.workspaceId as string
-
- const { data: existingAgent, isLoading } = useA2AAgentByWorkflow(workspaceId, workflowId)
-
- const createAgent = useCreateA2AAgent()
- const updateAgent = useUpdateA2AAgent()
- const deleteAgent = useDeleteA2AAgent()
- const publishAgent = usePublishA2AAgent()
-
- const blocks = useWorkflowStore((state) => state.blocks)
- const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
-
- const startBlockId = useMemo(() => {
- if (!blocks || Object.keys(blocks).length === 0) return null
- const candidate = TriggerUtils.findStartBlock(blocks, 'api')
- if (!candidate || candidate.path !== StartBlockPath.UNIFIED) return null
- return candidate.blockId
- }, [blocks])
-
- const startBlockInputFormat = useSubBlockStore((state) => {
- if (!workflowId || !startBlockId) return null
- const workflowValues = state.workflowValues[workflowId]
- const fromStore = workflowValues?.[startBlockId]?.inputFormat
- if (fromStore !== undefined) return fromStore
- const startBlock = blocks[startBlockId]
- return startBlock?.subBlocks?.inputFormat?.value ?? null
- })
-
- const missingFields = useMemo(() => {
- if (!startBlockId) return { input: false, data: false, files: false, any: false }
- const normalizedFields = normalizeInputFormatValue(startBlockInputFormat)
- const existingNames = new Set(
- normalizedFields
- .map((field) => field.name)
- .filter((n): n is string => typeof n === 'string' && n.trim() !== '')
- .map((n) => n.trim().toLowerCase())
- )
- const missing = {
- input: !existingNames.has('input'),
- data: !existingNames.has('data'),
- files: !existingNames.has('files'),
- any: false,
- }
- missing.any = missing.input || missing.data || missing.files
- return missing
- }, [startBlockId, startBlockInputFormat])
-
- const handleAddA2AInputs = () => {
- if (!startBlockId) return
-
- const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat)
- const newFields: InputFormatField[] = []
-
- if (missingFields.input) {
- newFields.push({
- id: generateId(),
- name: 'input',
- type: 'string',
- value: '',
- collapsed: false,
- })
- }
-
- if (missingFields.data) {
- newFields.push({
- id: generateId(),
- name: 'data',
- type: 'object',
- value: '',
- collapsed: false,
- })
- }
-
- if (missingFields.files) {
- newFields.push({
- id: generateId(),
- name: 'files',
- type: 'file[]',
- value: '',
- collapsed: false,
- })
- }
-
- if (newFields.length > 0) {
- const updatedFields = [...newFields, ...normalizedExisting]
- collaborativeSetSubblockValue(startBlockId, 'inputFormat', updatedFields)
- logger.info(
- `Added A2A input fields to Start block: ${newFields.map((f) => f.name).join(', ')}`
- )
- }
- }
-
- const [name, setName] = useState('')
- const [description, setDescription] = useState('')
- const [authScheme, setAuthScheme] = useState('apiKey')
- const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false)
- const [skillTags, setSkillTags] = useState([])
- const [language, setLanguage] = useState('curl')
- const [useStreamingExample, setUseStreamingExample] = useState(false)
- const [urlCopied, setUrlCopied] = useState(false)
- const [codeCopied, setCodeCopied] = useState(false)
-
- useEffect(() => {
- if (existingAgent) {
- setName(existingAgent.name)
- const savedDesc = existingAgent.description || ''
- setDescription(isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc)
- setPushNotificationsEnabled(existingAgent.capabilities?.pushNotifications ?? false)
- const schemes = existingAgent.authentication?.schemes || []
- if (schemes.includes('apiKey')) {
- setAuthScheme('apiKey')
- } else {
- setAuthScheme('none')
- }
- const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
- const savedTags = skills?.[0]?.tags
- setSkillTags(savedTags?.length ? savedTags : [])
- } else {
- setName(workflowName)
- setDescription(
- isDefaultDescription(workflowDescription, workflowName) ? '' : workflowDescription || ''
- )
- setAuthScheme('apiKey')
- setPushNotificationsEnabled(false)
- setSkillTags([])
- }
- }, [existingAgent, workflowName, workflowDescription])
-
- const hasFormChanges = useMemo(() => {
- if (!existingAgent) return false
- const savedSchemes = existingAgent.authentication?.schemes || []
- const savedAuthScheme = savedSchemes.includes('apiKey') ? 'apiKey' : 'none'
- const savedDesc = existingAgent.description || ''
- const normalizedSavedDesc = isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc
- const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
- const savedTags = skills?.[0]?.tags || []
- const tagsChanged =
- skillTags.length !== savedTags.length || skillTags.some((t, i) => t !== savedTags[i])
- return (
- name !== existingAgent.name ||
- description !== normalizedSavedDesc ||
- pushNotificationsEnabled !== (existingAgent.capabilities?.pushNotifications ?? false) ||
- authScheme !== savedAuthScheme ||
- tagsChanged
- )
- }, [
- existingAgent,
- name,
- description,
- pushNotificationsEnabled,
- authScheme,
- skillTags,
- workflowName,
- ])
-
- const hasWorkflowChanges = existingAgent ? !!workflowNeedsRedeployment : false
-
- const needsRepublish = existingAgent && (hasFormChanges || hasWorkflowChanges)
-
- useEffect(() => {
- onNeedsRepublishChange?.(!!needsRepublish)
- }, [needsRepublish, onNeedsRepublishChange])
-
- const canSave = name.trim().length > 0 && description.trim().length > 0
- useEffect(() => {
- onCanSaveChange?.(canSave)
- }, [canSave, onCanSaveChange])
-
- const isSubmitting =
- createAgent.isPending ||
- updateAgent.isPending ||
- deleteAgent.isPending ||
- publishAgent.isPending
-
- useEffect(() => {
- onSubmittingChange?.(isSubmitting)
- }, [isSubmitting, onSubmittingChange])
-
- const handleCreateOrUpdate = async () => {
- const capabilities: AgentCapabilities = {
- streaming: true,
- pushNotifications: pushNotificationsEnabled,
- stateTransitionHistory: true,
- }
-
- const authentication: AgentAuthentication = {
- schemes: authScheme === 'none' ? ['none'] : [authScheme],
- }
-
- try {
- if (existingAgent) {
- await updateAgent.mutateAsync({
- agentId: existingAgent.id,
- name: name.trim(),
- description: description.trim() || undefined,
- capabilities,
- authentication,
- skillTags,
- })
- } else {
- await createAgent.mutateAsync({
- workspaceId,
- workflowId,
- name: name.trim(),
- description: description.trim() || undefined,
- capabilities,
- authentication,
- skillTags,
- })
- }
- } catch (error) {
- logger.error('Failed to save A2A agent:', error)
- }
- }
-
- const handlePublish = async () => {
- if (!existingAgent) return
- try {
- await publishAgent.mutateAsync({
- agentId: existingAgent.id,
- workspaceId,
- action: 'publish',
- })
- } catch (error) {
- logger.error('Failed to publish A2A agent:', error)
- }
- }
-
- const handleUnpublish = async () => {
- if (!existingAgent) return
- try {
- await publishAgent.mutateAsync({
- agentId: existingAgent.id,
- workspaceId,
- action: 'unpublish',
- })
- } catch (error) {
- logger.error('Failed to unpublish A2A agent:', error)
- }
- }
-
- const handleDelete = async () => {
- if (!existingAgent) return
- try {
- await deleteAgent.mutateAsync({
- agentId: existingAgent.id,
- workspaceId,
- })
- setName(workflowName)
- setDescription(workflowDescription || '')
- } catch (error) {
- logger.error('Failed to delete A2A agent:', error)
- }
- }
-
- const handlePublishNewAgent = async () => {
- const capabilities: AgentCapabilities = {
- streaming: true,
- pushNotifications: pushNotificationsEnabled,
- stateTransitionHistory: true,
- }
-
- const authentication: AgentAuthentication = {
- schemes: authScheme === 'none' ? ['none'] : [authScheme],
- }
-
- try {
- if (!isDeployed && onDeployWorkflow) {
- await onDeployWorkflow()
- }
-
- const newAgent = await createAgent.mutateAsync({
- workspaceId,
- workflowId,
- name: name.trim(),
- description: description.trim() || undefined,
- capabilities,
- authentication,
- skillTags,
- })
-
- await publishAgent.mutateAsync({
- agentId: newAgent.id,
- workspaceId,
- action: 'publish',
- })
- } catch (error) {
- logger.error('Failed to publish A2A agent:', error)
- }
- }
-
- const handleUpdateAndRepublish = async () => {
- if (!existingAgent) return
-
- const capabilities: AgentCapabilities = {
- streaming: true,
- pushNotifications: pushNotificationsEnabled,
- stateTransitionHistory: true,
- }
-
- const authentication: AgentAuthentication = {
- schemes: authScheme === 'none' ? ['none'] : [authScheme],
- }
-
- try {
- if ((!isDeployed || workflowNeedsRedeployment) && onDeployWorkflow) {
- await onDeployWorkflow()
- }
-
- await updateAgent.mutateAsync({
- agentId: existingAgent.id,
- name: name.trim(),
- description: description.trim() || undefined,
- capabilities,
- authentication,
- skillTags,
- })
-
- await publishAgent.mutateAsync({
- agentId: existingAgent.id,
- workspaceId,
- action: 'publish',
- })
- } catch (error) {
- logger.error('Failed to update and republish A2A agent:', error)
- }
- }
-
- const baseUrl = getBaseUrl()
- const endpoint = existingAgent ? `${baseUrl}/api/a2a/serve/${existingAgent.id}` : null
-
- const additionalInputFields = useMemo(() => {
- const allFields = normalizeInputFormatValue(startBlockInputFormat)
- return allFields.filter(
- (field): field is InputFormatField & { name: string } =>
- !!field.name &&
- field.name.toLowerCase() !== 'input' &&
- field.name.toLowerCase() !== 'data' &&
- field.name.toLowerCase() !== 'files'
- )
- }, [startBlockInputFormat])
-
- const getExampleInputData = (): Record => {
- const data: Record = {}
- for (const field of additionalInputFields) {
- switch (field.type) {
- case 'string':
- data[field.name] = 'example'
- break
- case 'number':
- data[field.name] = 42
- break
- case 'boolean':
- data[field.name] = true
- break
- case 'object':
- data[field.name] = { key: 'value' }
- break
- case 'array':
- data[field.name] = [1, 2, 3]
- break
- default:
- data[field.name] = 'example'
- }
- }
- return data
- }
-
- const getJsonRpcPayload = (): Record => {
- const inputData = getExampleInputData()
- const hasAdditionalData = Object.keys(inputData).length > 0
-
- const parts: Array> = [{ kind: 'text', text: 'Hello, agent!' }]
- if (hasAdditionalData) {
- parts.push({ kind: 'data', data: inputData })
- }
-
- return {
- jsonrpc: '2.0',
- id: '1',
- method: useStreamingExample ? 'message/stream' : 'message/send',
- params: {
- message: {
- role: 'user',
- parts,
- },
- },
- }
- }
-
- const getCurlCommand = (): string => {
- if (!endpoint) return ''
- const payload = getJsonRpcPayload()
- const requiresAuth = authScheme !== 'none'
-
- switch (language) {
- case 'curl':
- return requiresAuth
- ? `curl -X POST \\
- -H "X-API-Key: $SIM_API_KEY" \\
- -H "Content-Type: application/json" \\
- -d '${JSON.stringify(payload)}' \\
- ${endpoint}`
- : `curl -X POST \\
- -H "Content-Type: application/json" \\
- -d '${JSON.stringify(payload)}' \\
- ${endpoint}`
-
- case 'python':
- return requiresAuth
- ? `import os
-import requests
-
-response = requests.post(
- "${endpoint}",
- headers={
- "X-API-Key": os.environ.get("SIM_API_KEY"),
- "Content-Type": "application/json"
- },
- json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
-)
-
-print(response.json())`
- : `import requests
-
-response = requests.post(
- "${endpoint}",
- headers={"Content-Type": "application/json"},
- json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
-)
-
-print(response.json())`
-
- case 'javascript':
- return requiresAuth
- ? `const response = await fetch("${endpoint}", {
- method: "POST",
- headers: {
- "X-API-Key": process.env.SIM_API_KEY,
- "Content-Type": "application/json"
- },
- body: JSON.stringify(${JSON.stringify(payload)})
-});
-
-const data = await response.json();
-console.log(data);`
- : `const response = await fetch("${endpoint}", {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify(${JSON.stringify(payload)})
-});
-
-const data = await response.json();
-console.log(data);`
-
- case 'typescript':
- return requiresAuth
- ? `const response = await fetch("${endpoint}", {
- method: "POST",
- headers: {
- "X-API-Key": process.env.SIM_API_KEY,
- "Content-Type": "application/json"
- },
- body: JSON.stringify(${JSON.stringify(payload)})
-});
-
-const data: Record = await response.json();
-console.log(data);`
- : `const response = await fetch("${endpoint}", {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify(${JSON.stringify(payload)})
-});
-
-const data: Record = await response.json();
-console.log(data);`
-
- default:
- return ''
- }
- }
-
- const handleCopyCommand = () => {
- navigator.clipboard.writeText(getCurlCommand())
- setCodeCopied(true)
- setTimeout(() => setCodeCopied(false), 2000)
- }
-
- if (isLoading) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- return (
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/index.ts
deleted file mode 100644
index bd1f5799ce9..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { A2aDeploy } from './a2a'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
index d79cee99f51..d7e0ad2ec7c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx
@@ -1,7 +1,6 @@
'use client'
import { useState } from 'react'
-import { Check, Clipboard } from 'lucide-react'
import {
Button,
ButtonGroup,
@@ -11,7 +10,8 @@ import {
Label,
Skeleton,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { Check, Clipboard } from 'lucide-react'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
interface WorkflowDeploymentInfo {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
index 12fe7164ce6..eee7671bcb7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
@@ -1,14 +1,12 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { AlertTriangle, Check } from 'lucide-react'
import {
ButtonGroup,
ButtonGroupItem,
ChipConfirmModal,
ChipInput,
+ cn,
Input,
Label,
Loader,
@@ -17,10 +15,12 @@ import {
type TagItem,
Textarea,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { AlertTriangle, Check } from 'lucide-react'
import { GeneratedPasswordInput } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/core/config/env'
-import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx
index 8e80b184d45..8f580283a51 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx
@@ -1,15 +1,15 @@
'use client'
+import { ChipLink } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
-import { ChipLink } from '@/components/emcn'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription'
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
interface DeployUpgradeGateProps {
- feature: 'API' | 'MCP' | 'A2A'
+ feature: 'API' | 'MCP'
}
export function DeployUpgradeGate({ feature }: DeployUpgradeGateProps) {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx
index a997946e344..9bca6377595 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
-import { getErrorMessage } from '@sim/utils/errors'
-import { useParams } from 'next/navigation'
import {
Badge,
ButtonGroup,
@@ -16,7 +14,9 @@ import {
ChipModalHeader,
Input,
Label,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { useParams } from 'next/navigation'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx
index 1a5cb845219..c8c0ede6138 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import dynamic from 'next/dynamic'
-import { useParams } from 'next/navigation'
import {
ChipConfirmModal,
ChipModal,
@@ -12,8 +10,10 @@ import {
ChipModalFooter,
ChipModalHeader,
chipFieldSurfaceClass,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+ cn,
+} from '@sim/emcn'
+import dynamic from 'next/dynamic'
+import { useParams } from 'next/navigation'
import {
useGenerateVersionDescription,
useUpdateDeploymentVersion,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx
index 055d3b0ae48..14781118029 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx
@@ -1,10 +1,9 @@
'use client'
import { useEffect, useRef, useState } from 'react'
-import { formatDateTime } from '@sim/utils/formatting'
-import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import {
Button,
+ cn,
Input,
Popover,
PopoverContent,
@@ -12,8 +11,9 @@ import {
PopoverTrigger,
Skeleton,
Tooltip,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { formatDateTime } from '@sim/utils/formatting'
+import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label'
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
index 5e7a258b8cc..e45d51bed99 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx
@@ -1,12 +1,12 @@
'use client'
import { useEffect, useState } from 'react'
-import { createLogger } from '@sim/logger'
import {
Button,
ButtonGroup,
ButtonGroupItem,
ChipConfirmModal,
+ cn,
Expand,
Label,
Modal,
@@ -16,8 +16,8 @@ import {
ModalHeader,
Skeleton,
Tooltip,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness'
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
index 22bb581cb27..630f2b53a5a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
@@ -1,4 +1,3 @@
-export { A2aDeploy } from './a2a'
export { ApiDeploy } from './api'
export { ChatDeploy, type ExistingChat } from './chat'
export { DeployUpgradeGate } from './deploy-upgrade-gate'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
index 38b7b8841ac..618870fb2c7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
@@ -1,20 +1,20 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { useParams } from 'next/navigation'
import {
Badge,
Button,
ChipCombobox,
ChipInput,
type ComboboxOption,
+ cn,
Label,
Skeleton,
Textarea,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { useParams } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
-import { cn } from '@/lib/core/utils/cn'
import {
extractDescriptionOverrides,
extractInputFormatFromBlocks,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
index dc1e6da189c..8bd6cba84e7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
@@ -1,10 +1,6 @@
'use client'
import { type ReactNode, useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { useQueryClient } from '@tanstack/react-query'
-import { useParams } from 'next/navigation'
import {
Badge,
Button,
@@ -21,7 +17,11 @@ import {
ModalTabsList,
ModalTabsTrigger,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { toError } from '@sim/utils/errors'
+import { useQueryClient } from '@tanstack/react-query'
+import { useParams } from 'next/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -35,7 +35,6 @@ import { syncLocalDraftFromServer } from '@/app/workspace/[workspaceId]/w/[workf
import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness'
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
import { normalizeName, startsWithUuid } from '@/executor/constants'
-import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
import { useApiKeys } from '@/hooks/queries/api-keys'
import {
invalidateDeploymentQueries,
@@ -56,7 +55,6 @@ import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
- A2aDeploy,
ApiDeploy,
ChatDeploy,
DeployUpgradeGate,
@@ -75,7 +73,7 @@ function GatedTabContent({
children,
}: {
gated: boolean
- feature: 'API' | 'MCP' | 'A2A'
+ feature: 'API' | 'MCP'
children: ReactNode
}) {
return gated ? : <>{children}>
@@ -103,9 +101,9 @@ interface WorkflowDeploymentInfoUI {
isPublicApi: boolean
}
-type TabView = 'general' | 'api' | 'chat' | 'mcp' | 'a2a'
+type TabView = 'general' | 'api' | 'chat' | 'mcp'
-const DEPLOY_MODAL_TABS = new Set(['general', 'api', 'chat', 'mcp', 'a2a'])
+const DEPLOY_MODAL_TABS = new Set(['general', 'api', 'chat', 'mcp'])
function isDeployModalTab(value: unknown): value is TabView {
return typeof value === 'string' && DEPLOY_MODAL_TABS.has(value as TabView)
@@ -144,10 +142,6 @@ export function DeployModal({
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [mcpToolSaveDisabledReason, setMcpToolSaveDisabledReason] = useState(null)
const [mcpActiveServerId, setMcpActiveServerId] = useState(null)
- const [a2aSubmitting, setA2aSubmitting] = useState(false)
- const [a2aCanSave, setA2aCanSave] = useState(false)
- const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
- const [showA2aDeleteConfirm, setShowA2aDeleteConfirm] = useState(false)
const [chatSuccess, setChatSuccess] = useState(false)
const chatSuccessTimeoutRef = useRef | null>(null)
@@ -202,13 +196,6 @@ export function DeployModal({
const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '')
const hasMcpServers = mcpServers.length > 0
- const { data: existingA2aAgent } = useA2AAgentByWorkflow(
- workflowWorkspaceId || '',
- workflowId || ''
- )
- const hasA2aAgent = !!existingA2aAgent
- const isA2aPublished = existingA2aAgent?.isPublished ?? false
-
const deployMutation = useDeployWorkflow()
const undeployMutation = useUndeployWorkflow()
const activateVersionMutation = useActivateDeploymentVersion()
@@ -526,43 +513,6 @@ export function DeployModal({
form?.requestSubmit()
}
- const handleA2aPublish = () => {
- const form = document.getElementById('a2a-deploy-form')
- const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
- publishTrigger?.click()
- }
-
- const handleA2aUnpublish = () => {
- const form = document.getElementById('a2a-deploy-form')
- const unpublishTrigger = form?.querySelector(
- '[data-a2a-unpublish-trigger]'
- ) as HTMLButtonElement
- unpublishTrigger?.click()
- }
-
- const handleA2aPublishNew = () => {
- const form = document.getElementById('a2a-deploy-form')
- const publishNewTrigger = form?.querySelector(
- '[data-a2a-publish-new-trigger]'
- ) as HTMLButtonElement
- publishNewTrigger?.click()
- }
-
- const handleA2aUpdateRepublish = () => {
- const form = document.getElementById('a2a-deploy-form')
- const updateRepublishTrigger = form?.querySelector(
- '[data-a2a-update-republish-trigger]'
- ) as HTMLButtonElement
- updateRepublishTrigger?.click()
- }
-
- const handleA2aDelete = () => {
- const form = document.getElementById('a2a-deploy-form')
- const deleteTrigger = form?.querySelector('[data-a2a-delete-trigger]') as HTMLButtonElement
- deleteTrigger?.click()
- setShowA2aDeleteConfirm(false)
- }
-
const isSubmitting = deployMutation.isPending || isFinalizingDeploy
const isUndeploying = undeployMutation.isPending
@@ -585,9 +535,6 @@ export function DeployModal({
{!permissionConfig.hideDeployMcp && (
MCP
)}
- {!permissionConfig.hideDeployA2a && (
- A2A
- )}
{!permissionConfig.hideDeployChatbot && (
Chat
)}
@@ -595,7 +542,7 @@ export function DeployModal({
- Configure and manage workflow deployment settings including API, MCP, A2A, and chat
+ Configure and manage workflow deployment settings including API, MCP, and chat
options.
{(deployError || deployWarnings.length > 0) && (
@@ -681,24 +628,6 @@ export function DeployModal({
)}
-
-
-
- {workflowId && (
-
- )}
-
-
@@ -805,77 +734,6 @@ export function DeployModal({
)}
- {activeTab === 'a2a' && !gateProgrammaticDeploy && (
-
- {hasA2aAgent ? (
- isA2aPublished ? (
-
- {a2aNeedsRepublish ? 'Update deployment' : 'Live'}
-
- ) : (
-
- Unpublished
-
- )
- ) : (
-
- )}
-
- {!hasA2aAgent && (
-
- {a2aSubmitting ? 'Publishing...' : 'Publish Agent'}
-
- )}
-
- {hasA2aAgent && isA2aPublished && (
- <>
-
- Unpublish
-
-
- {a2aSubmitting ? 'Updating...' : 'Update'}
-
- >
- )}
-
- {hasA2aAgent && !isA2aPublished && (
- <>
- setShowA2aDeleteConfirm(true)}
- disabled={a2aSubmitting}
- >
- Delete
-
-
- {a2aSubmitting ? 'Publishing...' : 'Publish'}
-
- >
- )}
-
-
- )}
@@ -901,26 +759,6 @@ export function DeployModal({
}}
/>
-
-
| undefined
onParamChange: (toolIndex: number, paramId: string, value: string) => void
disabled: boolean
@@ -44,6 +47,7 @@ export function ToolSubBlockRenderer({
toolIndex,
subBlock,
effectiveParamId,
+ toolType,
toolParams,
onParamChange,
disabled,
@@ -118,13 +122,15 @@ export function ToolSubBlockRenderer({
}
return (
-
+
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
index 976119bd960..d6470cac6bd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx
@@ -1,13 +1,11 @@
import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { ArrowLeft, ChevronRight, ServerIcon, WrenchIcon, XIcon } from 'lucide-react'
-import { useParams } from 'next/navigation'
import {
Badge,
Combobox,
type ComboboxOption,
type ComboboxOptionGroup,
+ cn,
Loader,
Popover,
PopoverContent,
@@ -15,9 +13,11 @@ import {
PopoverTrigger,
Switch,
Tooltip,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { ArrowLeft, ChevronRight, ServerIcon, WrenchIcon, XIcon } from 'lucide-react'
+import { useParams } from 'next/navigation'
import { McpIcon, WorkflowIcon } from '@/components/icons'
-import { cn } from '@/lib/core/utils/cn'
import {
getIssueBadgeLabel,
getIssueBadgeVariant,
@@ -2111,6 +2111,7 @@ export const ToolInput = memo(function ToolInput({
toolIndex={toolIndex}
subBlock={sbWithTitle}
effectiveParamId={effectiveParamId}
+ toolType={tool.type}
toolParams={tool.params}
onParamChange={handleParamChange}
disabled={disabled}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
index d7fac7a6b4c..5f41a03537f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx
@@ -1,19 +1,19 @@
import { useEffect, useRef, useState } from 'react'
-import { generateId } from '@sim/utils/id'
-import { Plus } from 'lucide-react'
-import { useParams } from 'next/navigation'
import {
Badge,
Button,
Combobox,
type ComboboxOption,
+ cn,
+ handleKeyboardActivation,
Input,
Label,
Textarea,
-} from '@/components/emcn'
-import { Trash } from '@/components/emcn/icons/trash'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
+} from '@sim/emcn'
+import { Trash } from '@sim/emcn/icons'
+import { generateId } from '@sim/utils/id'
+import { Plus } from 'lucide-react'
+import { useParams } from 'next/navigation'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type.ts
new file mode 100644
index 00000000000..05dac09c4db
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type.ts
@@ -0,0 +1,19 @@
+'use client'
+
+import { createContext, useContext } from 'react'
+
+const DependencyBlockTypeContext = createContext(null)
+
+/**
+ * Provider set by tool-input param rendering (value = the tool's block type, e.g. `gmail`).
+ */
+export const DependencyBlockTypeProvider = DependencyBlockTypeContext.Provider
+
+/**
+ * The block type whose config should drive dependency (`dependsOn`) canonical resolution
+ * for the current subblock. Null for normal blocks (resolve against the host block). Set
+ * to the tool's type for tool-input params, so a nested tool's selector resolves its
+ * parents against the TOOL's config (e.g. a Gmail tool's `credential` -> `oauthCredential`,
+ * which the host Agent block's subblocks don't define) and can fetch its options.
+ */
+export const useDependencyBlockType = () => useContext(DependencyBlockTypeContext)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts
index 338e3b9a11f..54576b7c819 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts
@@ -15,6 +15,7 @@ import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
+import { useDependencyBlockType } from './use-dependency-block-type'
/**
* Centralized dependsOn gating for sub-block components.
@@ -33,7 +34,13 @@ export function useDependsOnGate(
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
- const blockConfig = blockState?.type ? getBlock(blockState.type) : null
+
+ const dependencyBlockType = useDependencyBlockType()
+ const blockConfig = dependencyBlockType
+ ? getBlock(dependencyBlockType)
+ : blockState?.type
+ ? getBlock(blockState.type)
+ : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
index 71c94d10337..01ea37d8706 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx
@@ -1,4 +1,5 @@
import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react'
+import { Button, cn, Input, Label, Tooltip } from '@sim/emcn'
import { isEqual } from 'es-toolkit'
import {
AlertTriangle,
@@ -9,8 +10,6 @@ import {
ExternalLink,
} from 'lucide-react'
import { useParams } from 'next/navigation'
-import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
-import { cn } from '@/lib/core/utils/cn'
import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants'
import {
CheckboxList,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx
index bba1d3cb829..d7f22cb3354 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/subflow-editor/subflow-editor.tsx
@@ -1,7 +1,5 @@
'use client'
-import { ChevronUp } from 'lucide-react'
-import SimpleCodeEditor from 'react-simple-code-editor'
import {
Code as CodeEditor,
Combobox,
@@ -9,7 +7,9 @@ import {
getCodeEditorProps,
Input,
Label,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { ChevronUp } from 'lucide-react'
+import SimpleCodeEditor from 'react-simple-code-editor'
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
import {
formatDisplayText,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
index c0516b5c681..dbaa774a3af 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Button, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@sim/emcn'
import { isEqual } from 'es-toolkit'
import {
BookOpen,
@@ -16,7 +17,6 @@ import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
-import { Button, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@/components/emcn'
import { captureEvent } from '@/lib/posthog/client'
import {
buildCanonicalIndex,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts
index 915e7fb77dd..3c65b9ee003 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react'
+import { highlight, languages } from '@sim/emcn'
import { useParams } from 'next/navigation'
-import { highlight, languages } from '@/components/emcn'
import {
isLikelyReferenceSegment,
SYSTEM_REFERENCE_PREFIXES,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/toolbar-item-context-menu/toolbar-item-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/toolbar-item-context-menu/toolbar-item-context-menu.tsx
index cadc5872fc7..02411222deb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/toolbar-item-context-menu/toolbar-item-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/toolbar-item-context-menu/toolbar-item-context-menu.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
+import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@sim/emcn'
interface ToolbarItemContextMenuProps {
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
index 174e5b43e3d..dcf0338c368 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
@@ -11,12 +11,18 @@ import {
useRef,
useState,
} from 'react'
+import {
+ Button,
+ chipVariants,
+ cn,
+ Expandable,
+ ExpandableContent,
+ handleKeyboardActivation,
+ Info,
+} from '@sim/emcn'
import clsx from 'clsx'
import { ChevronDown, Search } from 'lucide-react'
import { usePostHog } from 'posthog-js/react'
-import { Button, chipVariants, Expandable, ExpandableContent, Info } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { captureEvent } from '@/lib/posthog/client'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
index fcceb0c48dd..8233f1a4bc4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
@@ -1,13 +1,6 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { useQueryClient } from '@tanstack/react-query'
-import { History, Plus, Square } from 'lucide-react'
-import { useParams, useRouter } from 'next/navigation'
-import { usePostHog } from 'posthog-js/react'
-import { useShallow } from 'zustand/react/shallow'
import {
BubbleChatClose,
BubbleChatPreview,
@@ -29,8 +22,15 @@ import {
PopoverTrigger,
Trash,
toast,
-} from '@/components/emcn'
-import { Download, Lock, Unlock } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Download, Lock, Unlock } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { toError } from '@sim/utils/errors'
+import { useQueryClient } from '@tanstack/react-query'
+import { History, Plus, Square } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
+import { useShallow } from 'zustand/react/shallow'
import { VariableIcon } from '@/components/icons'
import { requestJson } from '@/lib/api/client/request'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/components/replacement-controls/replacement-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/components/replacement-controls/replacement-controls.tsx
index 4841e05fce9..6b1ed9d1ed0 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/components/replacement-controls/replacement-controls.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/components/replacement-controls/replacement-controls.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Button, Combobox, Input } from '@/components/emcn'
+import { Button, Combobox, Input } from '@sim/emcn'
import type { WorkflowSearchReplacementOption } from '@/lib/workflows/search-replace/types'
interface ReplacementControlsProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx
index 7d0915e64ee..c5558e59807 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx
@@ -1,11 +1,10 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Button, cn, Input, toast } from '@sim/emcn'
import { ChevronDown, ChevronRight, ChevronUp, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
-import { Button, Input, toast } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts
index 426ef81dd9a..6001d27c0d2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts
@@ -1,6 +1,3 @@
export { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop'
export { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel'
-export {
- SubflowNodeComponent,
- type SubflowNodeData,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
+export { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
index 2a8b2ab989a..efdb4153e8f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
@@ -1,11 +1,7 @@
import { memo, useMemo } from 'react'
-import { RepeatIcon, SplitIcon } from 'lucide-react'
-import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
-import { Badge } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
-import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
-import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
+import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
+import { type NodeProps, useReactFlow } from 'reactflow'
+import { hasDiffStatus } from '@/lib/workflows/diff/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -13,53 +9,12 @@ import { useLastRunPath } from '@/stores/execution'
import { usePanelEditorStore } from '@/stores/panel'
/**
- * Data structure for subflow nodes (loop and parallel containers)
- */
-export interface SubflowNodeData {
- width?: number
- height?: number
- parentId?: string
- extent?: 'parent'
- isPreview?: boolean
- /** Whether this subflow is selected in preview mode */
- isPreviewSelected?: boolean
- kind: 'loop' | 'parallel'
- name?: string
- /** Execution status passed by preview/snapshot views */
- executionStatus?: 'success' | 'error' | 'not-executed'
- /** Whether the parent workflow is locked and should render as read-only */
- isWorkflowLocked?: boolean
-}
-
-const HANDLE_STYLE = {
- top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
- transform: 'translateY(-50%)',
-} as const
-
-/**
- * Reusable class names for Handle components.
- * Matches the styling pattern from workflow-block.tsx.
- */
-const getHandleClasses = (position: 'left' | 'right') => {
- const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
- const colorClasses = '!bg-[var(--workflow-edge)]'
-
- const positionClasses = {
- left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
- right:
- '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
- }
-
- return cn(baseClasses, colorClasses, positionClasses[position])
-}
-
-/**
- * Subflow node component for loop and parallel execution containers.
- * Renders a resizable container with a header displaying the block name and icon,
- * handles for connections, and supports nested execution contexts.
+ * Editor container for {@link SubflowNodeView}.
*
- * @param props - Node properties containing data and id
- * @returns Rendered subflow node component
+ * Resolves the subflow's enabled/locked/focus/diff/run state from the editor
+ * stores, computes its nesting depth from the ReactFlow node tree, and renders
+ * the pure view shared with the docs preview — injecting the editor-only
+ * {@link ActionBar} through the view's `actionBar` slot.
*/
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps) => {
const { getNodes } = useReactFlow()
@@ -68,7 +23,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
+ const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const isFocused = currentBlockId === id
- const isPreviewSelected = data?.isPreviewSelected || false
-
const lastRunPath = useLastRunPath()
const executionStatus = data.executionStatus
const runPathStatus: 'success' | 'error' | undefined =
@@ -93,8 +46,8 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps {
let level = 0
@@ -110,178 +63,21 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps {
- if (!hasRing) return undefined
- if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
- if (diffStatus === 'new') return 'var(--brand-accent)'
- if (diffStatus === 'edited') return 'var(--warning)'
- if (runPathStatus === 'success') {
- return executionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
- }
- if (runPathStatus === 'error') return 'var(--text-error)'
- return undefined
- }
- const ringColor = getRingColor()
-
return (
-
-
- {!isPreview &&
}
-
- {/* Header Section */}
-
setCurrentBlockId(id)}
- onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
- className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2 [&:active]:cursor-grabbing'
- style={{ pointerEvents: 'auto' }}
- >
-
-
-
-
-
- {blockName}
-
-
-
- {!isEnabled && disabled }
- {isLocked && locked }
-
-
-
- {/*
- * Subflow body background. Captures clicks to select the subflow in the
- * panel editor, matching the header click behavior. Child nodes and edges
- * are rendered as sibling divs at the viewport level by ReactFlow (not as
- * DOM children), so enabling pointer events here doesn't block them.
- */}
-
setCurrentBlockId(id)}
- onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
- />
-
- {!isPreview && canEditWorkflow && (
-
- )}
-
-
- {/* Subflow Start */}
-
- Start
-
-
-
-
-
- {/* Input handle on left middle */}
-
-
- {/* Output handle on right middle */}
-
-
-
+
setCurrentBlockId(id)}
+ actionBar={ }
+ />
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx
index 1495bc999c0..06bb376eac6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx
@@ -1,8 +1,6 @@
'use client'
import { memo } from 'react'
-import clsx from 'clsx'
-import { Filter } from 'lucide-react'
import {
Button,
Popover,
@@ -12,7 +10,9 @@ import {
PopoverScrollArea,
PopoverSection,
PopoverTrigger,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import clsx from 'clsx'
+import { Filter } from 'lucide-react'
import type {
BlockInfo,
TerminalFilters,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx
index d40cd119b6f..7df13785447 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx
@@ -1,13 +1,7 @@
'use client'
import { memo, type RefObject } from 'react'
-import {
- Popover,
- PopoverAnchor,
- PopoverContent,
- PopoverDivider,
- PopoverItem,
-} from '@/components/emcn'
+import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn'
import type {
ContextMenuPosition,
TerminalFilters,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx
index 0b3288cda9c..429ebf253e4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx
@@ -1,13 +1,7 @@
'use client'
import { memo, type RefObject } from 'react'
-import {
- Popover,
- PopoverAnchor,
- PopoverContent,
- PopoverDivider,
- PopoverItem,
-} from '@/components/emcn'
+import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn'
import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
export interface OutputContextMenuProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx
index a8c5c6f25a7..e92ca7f0f3a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx
@@ -11,9 +11,8 @@ import {
useRef,
useState,
} from 'react'
+import { Badge, ChevronDown, cn } from '@sim/emcn'
import { useVirtualizer } from '@tanstack/react-virtual'
-import { Badge, ChevronDown } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
import { isUserFileDisplayMetadata } from '@/lib/core/utils/user-file'
import {
isLargeArrayManifest,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx
index 47a14ce3147..7256230d994 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx
@@ -1,6 +1,17 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ Button,
+ Code,
+ Input,
+ Popover,
+ PopoverContent,
+ PopoverItem,
+ PopoverTrigger,
+ Tooltip,
+} from '@sim/emcn'
+import { Download } from '@sim/emcn/icons'
import clsx from 'clsx'
import {
ArrowDown,
@@ -16,17 +27,6 @@ import {
X,
} from 'lucide-react'
import Link from 'next/link'
-import {
- Button,
- Code,
- Input,
- Popover,
- PopoverContent,
- PopoverItem,
- PopoverTrigger,
- Tooltip,
-} from '@/components/emcn'
-import { Download } from '@/components/emcn/icons'
import {
OutputContextMenu,
StructuredOutput,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx
index fa54725e2ce..8388edc2acb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx
@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
-import { Badge } from '@/components/emcn'
+import { Badge } from '@sim/emcn'
import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx
index 43b2c9dc3c6..edf4e0e8401 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button/toggle-button.tsx
@@ -2,9 +2,9 @@
import type React from 'react'
import { memo } from 'react'
+import { Button } from '@sim/emcn'
import clsx from 'clsx'
import { ChevronDown } from 'lucide-react'
-import { Button } from '@/components/emcn'
export interface ToggleButtonProps {
isExpanded: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
index e5381ff35f5..103dcbb7139 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
@@ -2,23 +2,23 @@
import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { formatDuration } from '@sim/utils/formatting'
-import { useVirtualizer } from '@tanstack/react-virtual'
-import clsx from 'clsx'
-import { ArrowDown, ArrowUp, Database, MoreHorizontal, Palette, Pause, Trash2 } from 'lucide-react'
-import Link from 'next/link'
import {
Button,
ChevronDown,
+ handleKeyboardActivation,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
-} from '@/components/emcn'
-import { Download } from '@/components/emcn/icons'
+} from '@sim/emcn'
+import { Download } from '@sim/emcn/icons'
+import { formatDuration } from '@sim/utils/formatting'
+import { useVirtualizer } from '@tanstack/react-virtual'
+import clsx from 'clsx'
+import { ArrowDown, ArrowUp, Database, MoreHorizontal, Palette, Pause, Trash2 } from 'lucide-react'
+import Link from 'next/link'
import { getEnv, isTruthy } from '@/lib/core/config/env'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { sendMothershipMessage } from '@/lib/mothership/events'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
index 3345e3859a8..eff41e09f82 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx
@@ -1,9 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { Plus, X } from 'lucide-react'
-import Editor from 'react-simple-code-editor'
-import { useShallow } from 'zustand/react/shallow'
import {
Badge,
Button,
@@ -11,14 +8,17 @@ import {
Combobox,
type ComboboxOption,
calculateGutterWidth,
+ cn,
getCodeEditorProps,
highlight,
Input,
Label,
languages,
-} from '@/components/emcn'
-import { Trash } from '@/components/emcn/icons/trash'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { Trash } from '@sim/emcn/icons'
+import { Plus, X } from 'lucide-react'
+import Editor from 'react-simple-code-editor'
+import { useShallow } from 'zustand/react/shallow'
import { validateName } from '@/lib/core/utils/validation'
import {
useFloatBoundarySync,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx
index 093b0645c1a..89848f74c12 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
+import { cn } from '@sim/emcn'
import { SendIcon, XIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
-import { cn } from '@/lib/core/utils/cn'
interface WandPromptBarProps {
isVisible: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index 385b81ecb7f..6c7976327e5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -1,16 +1,13 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
+import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
import { isEqual } from 'es-toolkit'
import { useParams } from 'next/navigation'
-import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
+import { type NodeProps, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
-import { Badge, Tooltip } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/shared'
import { getProviderIdFromServiceId } from '@/lib/oauth'
-import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
@@ -373,25 +370,7 @@ const SubBlockRow = memo(function SubBlockRow({
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return (
-
-
- {title}
-
- {displayValue !== undefined && (
-
- {displayValue}
-
- )}
-
+
)
}, areSubBlockRowPropsEqual)
@@ -632,39 +611,16 @@ export const WorkflowBlock = memo(function WorkflowBlock({
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
- /**
- * Reusable styles and positioning for Handle components.
- */
- const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => {
- const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150'
- const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]'
-
- const positionClasses = {
- left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
- right:
- '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
- top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full',
- bottom:
- '!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full',
- }
-
- return cn(baseClasses, colorClasses, positionClasses[position])
- }
-
- const getHandleStyle = (position: 'horizontal' | 'vertical') => {
- if (position === 'horizontal') {
- return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
- }
- return { left: '50%', transform: 'translateX(-50%)' }
- }
-
/**
* Compute per-condition rows (title/value/id) for condition blocks so we can render
* one row per condition statement with its own output handle.
*/
const conditionRows = useMemo(() => {
if (type !== 'condition') return [] as { id: string; title: string; value: string }[]
- return getConditionRows(id, topologySubBlocks.conditions?.value)
+ return getConditionRows(id, topologySubBlocks.conditions?.value).map((cond) => ({
+ ...cond,
+ value: getDisplayValue(cond.value),
+ }))
}, [type, topologySubBlocks, id])
/**
@@ -674,7 +630,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return [] as { id: string; value: string }[]
- return getRouterRows(id, topologySubBlocks.routes?.value)
+ return getRouterRows(id, topologySubBlocks.routes?.value).map((route) => ({
+ ...route,
+ value: getDisplayValue(route.value),
+ }))
}, [type, topologySubBlocks, id])
/**
@@ -741,399 +700,116 @@ export const WorkflowBlock = memo(function WorkflowBlock({
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
- return (
-
-
handleKeyboardActivation(event, handleClick)}
- className={cn(
- 'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
- )}
- >
- {isPending && (
-
- Next Step
-
- )}
-
- {!data.isPreview && !data.isEmbedded && (
-
- )}
-
- {shouldShowDefaultHandles && (
-
{
- if (connection.source === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- )}
-
-
-
-
- {isWorkflowSelector &&
- childWorkflowId &&
- typeof childIsDeployed === 'boolean' &&
- (!childIsDeployed || childNeedsRedeploy) && (
-
-
- {
- e.stopPropagation()
- if (childWorkflowId && !isDeploying && userPermissions.canAdmin) {
- deployChildWorkflow({ workflowId: childWorkflowId })
- }
- }}
- >
- {isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'}
-
-
-
-
- {!userPermissions.canAdmin
- ? 'Admin permission required to deploy'
- : !childIsDeployed
- ? 'Click to deploy'
- : 'Click to redeploy'}
-
-
-
- )}
- {!isEnabled && !isLocked &&
disabled }
- {isLocked &&
locked }
-
- {type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
-
-
- {
- e.stopPropagation()
- if (scheduleInfo?.id) {
- reactivateSchedule(scheduleInfo.id)
- }
- }}
- >
- disabled
-
-
-
- Click to reactivate
-
-
- )}
-
- {showWebhookIndicator && (
-
-
-
- Webhook
-
-
-
- {webhookProvider && webhookPath ? (
- <>
- {getProviderName(webhookProvider)} Webhook
- Path: {webhookPath}
- >
- ) : (
-
- This workflow is triggered by a webhook.
-
- )}
-
-
- )}
-
- {isWebhookConfigured && isWebhookDisabled && webhookId && (
-
-
- {
- e.stopPropagation()
- reactivateWebhook(webhookId)
- }}
- >
- disabled
-
-
-
- Click to reactivate
-
-
- )}
- {/* {isActive && (
-
- )} */}
-
-
-
- {hasContentBelowHeader && (
-
- {type === 'condition' ? (
- conditionRows.map((cond) => (
-
- ))
- ) : type === 'router_v2' ? (
- <>
-
- {routerRows.map((route, index) => (
+ const wouldCreateConnectionCycle = (source: string, target: string) =>
+ wouldCreateCycle(useWorkflowStore.getState().edges, source, target)
+
+ const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined
+
+ const rows =
+ type === 'condition' || type === 'router_v2' ? null : (
+ <>
+ {subBlockRows.map((row, rowIndex) =>
+ row.flatMap((subBlock) => {
+ const rawValue = subBlockState[subBlock.id]?.value
+ if (subBlock.type === 'mcp-dynamic-args') {
+ const schema = subBlockState._toolSchema?.value as
+ | { properties?: Record }
+ | undefined
+ const properties = schema?.properties
+ if (properties && typeof properties === 'object') {
+ const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record<
+ string,
+ unknown
+ >
+ return Object.keys(properties).map((paramName) => (
- ))}
- >
- ) : (
- subBlockRows.map((row, rowIndex) =>
- row.flatMap((subBlock) => {
- const rawValue = subBlockState[subBlock.id]?.value
- if (subBlock.type === 'mcp-dynamic-args') {
- const schema = subBlockState._toolSchema?.value as
- | { properties?: Record }
- | undefined
- const properties = schema?.properties
- if (properties && typeof properties === 'object') {
- const args = (
- rawValue && typeof rawValue === 'object' ? rawValue : {}
- ) as Record
- return Object.keys(properties).map((paramName) => (
-
- ))
- }
- return []
- }
- return [
- ,
- ]
- })
- )
- )}
- {shouldShowDefaultHandles && }
-
- )}
-
- {type === 'condition' && (
- <>
- {conditionRows.map((cond, condIndex) => {
- const topOffset =
- HANDLE_POSITIONS.CONDITION_START_Y +
- condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
- return (
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- )
- })}
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- >
- )}
-
- {type === 'router_v2' && (
- <>
- {routerRows.map((route, routeIndex) => {
- // +1 row offset for context row at the top
- const topOffset =
- HANDLE_POSITIONS.CONDITION_START_Y +
- (routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
- return (
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- )
- })}
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- >
+ ))
+ }
+ return []
+ }
+ return [
+ ,
+ ]
+ })
)}
+ >
+ )
- {type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
- <>
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
-
- {shouldShowDefaultHandles && (
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- )}
- >
- )}
- {hasRing && (
-
- )}
-
-
+ return (
+ {
+ if (childWorkflowId && !isDeploying && userPermissions.canAdmin) {
+ deployChildWorkflow({ workflowId: childWorkflowId })
+ }
+ }}
+ shouldShowScheduleBadge={shouldShowScheduleBadge}
+ scheduleIsDisabled={Boolean(scheduleInfo?.isDisabled)}
+ onReactivateSchedule={() => {
+ if (scheduleInfo?.id) {
+ reactivateSchedule(scheduleInfo.id)
+ }
+ }}
+ showWebhookIndicator={showWebhookIndicator}
+ webhookProvider={webhookProvider}
+ webhookPath={webhookPath}
+ webhookProviderName={webhookProviderName}
+ isWebhookConfigured={isWebhookConfigured}
+ isWebhookDisabled={isWebhookDisabled}
+ webhookId={webhookId}
+ onReactivateWebhook={() => {
+ if (webhookId) {
+ reactivateWebhook(webhookId)
+ }
+ }}
+ onSelect={handleClick}
+ contentRef={contentRef}
+ actionBar={
+ !data.isPreview && !data.isEmbedded ? (
+
+ ) : undefined
+ }
+ rows={rows}
+ />
)
}, shouldSkipBlockRender)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx
index 39c97cb0b0f..bbdab26c41f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx
@@ -1,10 +1,6 @@
'use client'
import { memo, useCallback, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { Scan } from 'lucide-react'
-import { useReactFlow } from 'reactflow'
-import { useShallow } from 'zustand/react/shallow'
import {
Button,
ChevronDown,
@@ -18,7 +14,11 @@ import {
Redo,
Tooltip,
Undo,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { Scan } from 'lucide-react'
+import { useReactFlow } from 'reactflow'
+import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
index ed4840eae81..eff2ef11c30 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
@@ -1,8 +1,7 @@
import { memo, useMemo } from 'react'
-import { X } from 'lucide-react'
-import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
+import { type EdgeDiffStatus, WorkflowEdgeView } from '@sim/workflow-renderer'
+import type { EdgeProps } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
-import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useLastRunEdges } from '@/stores/execution'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -12,35 +11,14 @@ interface WorkflowEdgeProps extends EdgeProps {
targetHandle?: string | null
}
-const WorkflowEdgeComponent = ({
- id,
- sourceX,
- sourceY,
- targetX,
- targetY,
- sourcePosition,
- targetPosition,
- data,
- style,
- source,
- target,
- sourceHandle,
- targetHandle,
-}: WorkflowEdgeProps) => {
- const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
-
- const [edgePath, labelX, labelY] = getSmoothStepPath({
- sourceX,
- sourceY,
- sourcePosition,
- targetX,
- targetY,
- targetPosition,
- borderRadius: 8,
- offset: isHorizontal ? 30 : 20,
- })
-
- const isSelected = data?.isSelected ?? false
+/**
+ * Editor container for {@link WorkflowEdgeView}.
+ *
+ * Reads the diff and execution stores, resolves the edge's diff/run state, and
+ * passes it to the pure renderer shared with the docs preview.
+ */
+const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
+ const { id, data, source, target, sourceHandle, targetHandle } = props
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
useShallow((state) => ({
@@ -51,14 +29,12 @@ const WorkflowEdgeComponent = ({
)
const lastRunEdges = useLastRunEdges()
- const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
- const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
const previewExecutionStatus = (
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
)?.executionStatus
- const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id)
+ const runStatus = previewExecutionStatus || lastRunEdges.get(id)
- const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
+ const diffStatus = useMemo((): EdgeDiffStatus => {
if (data?.isDeleted) return 'deleted'
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
@@ -84,84 +60,14 @@ const WorkflowEdgeComponent = ({
targetHandle,
])
- const edgeStyle = useMemo(() => {
- let color = 'var(--workflow-edge)'
- let opacity = 1
-
- if (edgeDiffStatus === 'deleted') {
- color = 'var(--text-error)'
- opacity = 0.7
- } else if (edgeDiffStatus === 'new') {
- color = 'var(--brand-accent)'
- } else if (edgeRunStatus === 'success') {
- // Use green for preview mode, default for canvas execution
- color = previewExecutionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
- } else if (edgeRunStatus === 'error') {
- color = 'var(--text-error)'
- } else if (isErrorEdge) {
- // Error edges that weren't taken stay red
- color = 'var(--text-error)'
- }
-
- if (isSelected) {
- opacity = 0.5
- }
-
- return {
- ...(style ?? {}),
- strokeWidth: edgeDiffStatus
- ? 3
- : edgeRunStatus === 'success' || edgeRunStatus === 'error'
- ? 2.5
- : isSelected
- ? 2.5
- : 2,
- stroke: color,
- strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
- opacity,
- }
- }, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus])
-
return (
- <>
-
-
- {isSelected && (
-
- {
- e.preventDefault()
- e.stopPropagation()
-
- if (data?.onDelete) {
- data.onDelete(id)
- }
- }}
- >
-
-
-
- )}
- >
+
)
}
-/**
- * Workflow edge component with execution status and diff visualization.
- *
- * @remarks
- * Edge coloring priority:
- * 1. Diff status (deleted/new) - for version comparison
- * 2. Execution status (success/error) - for run visualization
- * 3. Error edge default (red) - for untaken error paths
- * 4. Default edge color - normal workflow connections
- */
export const WorkflowEdge = memo(WorkflowEdgeComponent)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts
index f8578d95eab..77d5cde2635 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts
@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { useReactFlow } from 'reactflow'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
calculateContainerDimensions,
clampPositionToContainer,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts
index 239a70fa233..1ac9e042573 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts
@@ -1,8 +1,8 @@
import { useCallback, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
-import { toast } from '@/components/emcn'
import { requestRaw } from '@/lib/api/client'
import { isApiClientError } from '@/lib/api/client/errors'
import { wandGenerateStreamContract } from '@/lib/api/contracts'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
index 58ab9fa7307..49069eac243 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
@@ -6,7 +7,6 @@ import { generateId, generateShortId } from '@sim/utils/id'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
-import { toast } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
index 3fad6a20280..b4996dd90ad 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts
@@ -1,4 +1,4 @@
-import { cn } from '@/lib/core/utils/cn'
+import { cn } from '@sim/emcn'
export type BlockDiffStatus = 'new' | 'edited' | null | undefined
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts
index 01068ff1115..ccefe9f3d3f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts
@@ -1,4 +1,4 @@
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { getBlock } from '@/blocks/registry'
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts
index 18b3c4ec186..2fa772f78bb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts
@@ -1,5 +1,5 @@
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import type { Edge, Node } from 'reactflow'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index 9f38b879e0d..1e87115e592 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -13,15 +13,16 @@ import ReactFlow, {
useReactFlow,
} from 'reactflow'
import 'reactflow/dist/style.css'
+import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
+import type { SubflowNodeData } from '@sim/workflow-renderer'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { useShallow } from 'zustand/react/shallow'
-import { toast } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import type { OAuthProvider } from '@/lib/oauth'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -36,7 +37,6 @@ import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/compone
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace'
-import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import {
useAutoLayout,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu/preview-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu/preview-context-menu.tsx
index 2e84146c31a..5ff50058b1a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu/preview-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu/preview-context-menu.tsx
@@ -1,14 +1,8 @@
'use client'
import type { RefObject } from 'react'
+import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn'
import { createPortal } from 'react-dom'
-import {
- Popover,
- PopoverAnchor,
- PopoverContent,
- PopoverDivider,
- PopoverItem,
-} from '@/components/emcn'
interface PreviewContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
index 7c1e7588fe9..63c30769824 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx
@@ -1,6 +1,19 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import {
+ Badge,
+ Button,
+ ChevronDown,
+ Code,
+ Combobox,
+ cn,
+ FieldDivider,
+ handleKeyboardActivation,
+ Input,
+ Label,
+ Tooltip,
+} from '@sim/emcn'
import { formatDuration } from '@sim/utils/formatting'
import {
ArrowDown,
@@ -18,19 +31,6 @@ import {
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { ReactFlowProvider } from 'reactflow'
-import {
- Badge,
- Button,
- ChevronDown,
- Code,
- Combobox,
- FieldDivider,
- Input,
- Label,
- Tooltip,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
import {
buildCanonicalIndex,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx
index 7130ec1b7b6..0fbe922788d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx
@@ -1,8 +1,8 @@
'use client'
import { type CSSProperties, memo, useMemo } from 'react'
+import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
import { Handle, type NodeProps, Position } from 'reactflow'
-import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
getDisplayValue,
resolveDropdownLabel,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx
index 7cb8fa07a46..56b91370308 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx
@@ -1,11 +1,10 @@
'use client'
import { memo } from 'react'
+import { Badge, cn } from '@sim/emcn'
+import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
-import { Badge } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
/** Execution status for subflows in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
index eb5a8fe2b81..ee0790ad673 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
@@ -13,9 +13,9 @@ import ReactFlow, {
} from 'reactflow'
import 'reactflow/dist/style.css'
+import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { cn } from '@/lib/core/utils/cn'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx
index adaab38e200..f4755c59241 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx
@@ -2,10 +2,9 @@
import type React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
+import { Button, cn, Tooltip } from '@sim/emcn'
import { ArrowLeft } from 'lucide-react'
-import { Button, Tooltip } from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
-import { cn } from '@/lib/core/utils/cn'
import { PreviewEditor } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-editor'
import {
getLeftmostBlockId,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
index 7cb4d877c4a..649b523f9bd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
@@ -1,8 +1,7 @@
import { type MouseEvent as ReactMouseEvent, useState } from 'react'
-import { Folder, MoreHorizontal, Plus } from 'lucide-react'
-import Link from 'next/link'
import {
chipVariants,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -12,9 +11,10 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Pencil, SquareArrowUpRight } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { Pencil, SquareArrowUpRight } from '@sim/emcn/icons'
+import { Folder, MoreHorizontal, Plus } from 'lucide-react'
+import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx
index 61f7f0236bc..bb162d2c44d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx
@@ -1,9 +1,9 @@
'use client'
import { memo, useMemo, useState } from 'react'
+import { cn } from '@sim/emcn'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
-import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx
index 4fa50c4dc63..41fe122cd38 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx
@@ -2,6 +2,13 @@
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
+import {
+ ChipModal,
+ ChipModalBody,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useMutation } from '@tanstack/react-query'
import imageCompression from 'browser-image-compression'
@@ -9,13 +16,6 @@ import { X } from 'lucide-react'
import Image from 'next/image'
import { Controller, useForm } from 'react-hook-form'
import { z } from 'zod'
-import {
- ChipModal,
- ChipModalBody,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
-} from '@/components/emcn'
const logger = createLogger('HelpModal')
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx
index bdc18cfab7b..e3abe0cd9c7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx
@@ -1,12 +1,7 @@
'use client'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/emcn'
-import { Duplicate, SquareArrowUpRight } from '@/components/emcn/icons'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
+import { Duplicate, SquareArrowUpRight } from '@sim/emcn/icons'
interface NavItemContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
index 2c795057f7d..7acf9f9bcf0 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
@@ -2,9 +2,9 @@
import type { ComponentType } from 'react'
import { memo } from 'react'
+import { cn } from '@sim/emcn'
+import { File, Workflow } from '@sim/emcn/icons'
import { Command } from 'cmdk'
-import { File, Workflow } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import {
COMMAND_ITEM_CLASSNAME,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
index bf69aacea08..4a6eaed1524 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
@@ -2,8 +2,8 @@
import type { ComponentType } from 'react'
import { memo } from 'react'
+import { Database, Table } from '@sim/emcn/icons'
import { Command } from 'cmdk'
-import { Database, Table } from '@/components/emcn/icons'
import {
MemoizedActionItem,
MemoizedCommandItem,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
index 7466c52ece3..1c5b4fd32bc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
@@ -1,13 +1,7 @@
'use client'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { Command } from 'cmdk'
-import { Scan } from 'lucide-react'
-import { useParams, useRouter } from 'next/navigation'
-import { usePostHog } from 'posthog-js/react'
-import { createPortal } from 'react-dom'
-import { Library } from '@/components/emcn'
+import { cn, Library } from '@sim/emcn'
import {
Calendar,
Database,
@@ -20,13 +14,18 @@ import {
Key,
Play,
Plus,
+ Search,
Send,
Settings,
Table,
Upload,
-} from '@/components/emcn/icons'
-import { Search } from '@/components/emcn/icons/search'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { Command } from 'cmdk'
+import { Scan } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
+import { createPortal } from 'react-dom'
import { captureEvent } from '@/lib/posthog/client'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
index 6c70c0bcf55..c2d9284665c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
@@ -1,14 +1,13 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { ChevronDown, ChipConfirmModal, chipVariants, cn } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, usePathname, useRouter } from 'next/navigation'
-import { ChevronDown, ChipConfirmModal, chipVariants } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { isHosted } from '@/lib/core/config/env-flags'
-import { cn } from '@/lib/core/utils/cn'
import { getUserRole } from '@/lib/workspaces/organization'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
index 094e1d5a43d..47e19a1bc5a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
@@ -1,13 +1,12 @@
'use client'
-import { Pin, PinOff } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from '@/components/emcn'
+} from '@sim/emcn'
import {
Download,
Duplicate,
@@ -19,10 +18,13 @@ import {
Mail,
Pencil,
Plus,
+ Rocket,
+ Shuffle,
SquareArrowUpRight,
Trash,
Unlock,
-} from '@/components/emcn/icons'
+} from '@sim/emcn/icons'
+import { Pin, PinOff } from 'lucide-react'
interface ContextMenuProps {
isOpen: boolean
@@ -68,6 +70,10 @@ interface ContextMenuProps {
onUploadLogo?: () => void
showUploadLogo?: boolean
disableUploadLogo?: boolean
+ onFork?: () => void
+ onSync?: () => void
+ showFork?: boolean
+ showSync?: boolean
}
/**
@@ -118,6 +124,10 @@ export function ContextMenu({
onUploadLogo,
showUploadLogo = false,
disableUploadLogo = false,
+ onFork,
+ onSync,
+ showFork = false,
+ showSync = false,
}: ContextMenuProps) {
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasStatusSection =
@@ -131,6 +141,7 @@ export function ContextMenu({
(showLock && onToggleLock) ||
(showUploadLogo && onUploadLogo)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
+ const hasForkSection = (showFork && onFork) || (showSync && onSync)
return (
!open && onClose()} modal={false}>
@@ -294,6 +305,35 @@ export function ContextMenu({
)}
{(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
+ hasForkSection && }
+ {showFork && onFork && (
+ {
+ onFork()
+ onClose()
+ }}
+ >
+
+ Manage Forks
+
+ )}
+ {showSync && onSync && (
+ {
+ onSync()
+ onClose()
+ }}
+ >
+
+ Sync workspace
+
+ )}
+
+ {(hasNavigationSection ||
+ hasStatusSection ||
+ hasEditSection ||
+ hasCopySection ||
+ hasForkSection) &&
(showLeave || showDelete) && }
{showLeave && onLeave && (
`${n} ${noun}${n === 1 ? '' : 's'}`
+
+/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
+function countList(pairs: Array<[number | undefined, string]>): string {
+ return pairs
+ .filter(([n]) => (n ?? 0) > 0)
+ .map(([n, verb]) => `${n} ${verb}`)
+ .join(' · ')
+}
+
+/** A named, collapsible group (one resource kind or change action) of a job's report. */
+interface ReportGroup {
+ label: string
+ names: string[]
+}
+
+/** A job's expanded report: collapsible named groups plus plain notes (counts / warnings). */
+interface JobReport {
+ groups: ReportGroup[]
+ notes: Array<{ value: string; warning?: boolean }>
+}
+
+/** The audit-row title, derived per kind from the job's metadata. */
+function jobTitle(job: BackgroundWorkItem): string {
+ const m = job.metadata
+ switch (job.kind) {
+ case 'fork_content_copy':
+ return m?.childWorkspaceName
+ ? `Forked into "${m.childWorkspaceName}"`
+ : (job.message ?? 'Fork')
+ case 'fork_sync':
+ if (!m?.otherWorkspaceName) return job.message ?? 'Sync'
+ return m.direction === 'pull'
+ ? `Pulled from "${m.otherWorkspaceName}"`
+ : `Pushed to "${m.otherWorkspaceName}"`
+ case 'fork_rollback':
+ return m?.otherWorkspaceName
+ ? `Undid sync from "${m.otherWorkspaceName}"`
+ : (job.message ?? 'Rollback')
+ default:
+ return job.message ?? 'Activity'
+ }
+}
+
+/** Build a job's report (collapsible named groups + plain notes) from its metadata. */
+function jobReport(job: BackgroundWorkItem): JobReport {
+ const m = job.metadata
+ const groups: ReportGroup[] = []
+ const notes: JobReport['notes'] = []
+ if (!m) return { groups, notes }
+
+ const addGroup = (label: string, names: string[] | undefined) => {
+ if (names && names.length > 0) groups.push({ label, names })
+ }
+
+ if (job.kind === 'fork_sync') {
+ addGroup('Updated', m.updatedNames)
+ addGroup('Created', m.createdNames)
+ addGroup('Archived', m.archivedNames)
+ // Pre-names entries fall back to the count summary (redeployed mirrors updated).
+ if (groups.length === 0) {
+ const counts = countList([
+ [m.updated, 'updated'],
+ [m.created, 'created'],
+ [m.archived, 'archived'],
+ ])
+ if (counts) notes.push({ value: counts })
+ }
+ if (m.needsConfiguration && m.needsConfiguration.length > 0) {
+ for (const item of m.needsConfiguration) {
+ notes.push({
+ value: `${item.workflowName} — re-check ${item.blocks.join(', ')}`,
+ warning: true,
+ })
+ }
+ }
+ if (m.clearedOptional && m.clearedOptional.length > 0) {
+ for (const item of m.clearedOptional) {
+ notes.push({
+ value: `${item.workflowName} — optional cleared in ${item.blocks.join(', ')}`,
+ })
+ }
+ }
+ if (m.deployFailed && m.deployFailed > 0) {
+ notes.push({ value: `${plural(m.deployFailed, 'workflow')} failed to deploy`, warning: true })
+ }
+ return { groups, notes }
+ }
+
+ if (job.kind === 'fork_rollback') {
+ const counts = countList([
+ [m.restored, 'restored'],
+ [m.unarchived, 'unarchived'],
+ [m.removed, 'removed'],
+ [m.skipped, 'skipped'],
+ ])
+ if (counts) notes.push({ value: counts })
+ return { groups, notes }
+ }
+
+ // fork_content_copy: a named breakdown of everything copied, by kind.
+ addGroup('Workflows', m.workflowNames)
+ addGroup('Knowledge bases', m.knowledgeBaseNames)
+ addGroup('Tables', m.tableNames)
+ addGroup('Files', m.fileNames)
+ addGroup('Custom tools', m.customToolNames)
+ addGroup('Skills', m.skillNames)
+ addGroup('MCP servers', m.mcpServerNames)
+ // Pre-names entries fall back to the per-kind counts.
+ if (groups.length === 0) {
+ const counts = [
+ [m.workflowsCopied, 'workflow'],
+ [m.knowledgeBases, 'knowledge base'],
+ [m.tables, 'table'],
+ [m.files, 'file'],
+ ]
+ .filter(([n]) => ((n as number | undefined) ?? 0) > 0)
+ .map(([n, noun]) => plural(n as number, noun as string))
+ .join(' · ')
+ if (counts) notes.push({ value: counts })
+ }
+ if (m.failed && m.failed > 0) {
+ notes.push({ value: `${plural(m.failed, 'resource')} failed to copy`, warning: true })
+ }
+ return { groups, notes }
+}
+
+/** Status indicator: the platform loader while active, a colored dot once terminal. */
+function JobStatusIndicator({ status }: { status: BackgroundWorkItem['status'] }) {
+ if (status === 'pending' || status === 'processing') {
+ return
+ }
+ const color =
+ status === 'failed'
+ ? 'bg-[var(--text-error)]'
+ : status === 'completed_with_warnings'
+ ? 'bg-[var(--badge-amber-text)]'
+ : 'bg-[var(--indicator-active)]'
+ const label =
+ status === 'failed'
+ ? 'Failed'
+ : status === 'completed_with_warnings'
+ ? 'Completed with warnings'
+ : 'Done'
+ return
+}
+
+/** A collapsed report group ("Label N ⌄") that expands to its scrollable name list. */
+function ReportGroupRow({ group }: { group: ReportGroup }) {
+ const [open, setOpen] = useState(false)
+ return (
+
+
setOpen((value) => !value)}
+ className='flex w-full items-center gap-2 text-left text-[var(--text-secondary)] text-caption transition-colors hover:text-[var(--text-primary)]'
+ >
+ {group.label}
+ {group.names.length}
+
+
+ {open ? (
+
+ {group.names.map((name) => (
+
+ {name}
+
+ ))}
+
+ ) : null}
+
+ )
+}
+
+/** One audit-log row: status + title + actor; expands to the timestamp + report. */
+function ForkJobRow({ job }: { job: BackgroundWorkItem }) {
+ const [expanded, setExpanded] = useState(false)
+ const report = jobReport(job)
+ const title = jobTitle(job)
+
+ return (
+
+ setExpanded((value) => !value)}
+ onKeyDown={(event) => {
+ if (event.target !== event.currentTarget) return
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ setExpanded((value) => !value)
+ }
+ }}
+ >
+
+
+
+ {title}
+
+
+
+ {job.metadata?.actorName || '—'}
+
+
+
+ {expanded ? (
+
+
+ {formatDateTime(new Date(job.startedAt))}
+
+ {report.groups.map((group) => (
+
+ ))}
+ {report.notes.map((note) => (
+
+ {note.value}
+
+ ))}
+ {job.error ? (
+ {job.error}
+ ) : null}
+
+ ) : null}
+
+ )
+}
+
+/** Audit-log table of fork jobs, mirroring the deployment-versions table chrome. */
+function ForkJobsTable({ jobs }: { jobs: BackgroundWorkItem[] }) {
+ return (
+
+
+ Activity
+ By
+
+
+
+ {jobs.map((job) => (
+
+ ))}
+
+
+ )
+}
+
+interface ForkActivityPanelProps {
+ /** The triggering operation is currently running (mutation in flight). */
+ pending?: boolean
+ pendingLabel?: string
+ /** Poll the durable fork-job audit trail for this workspace. */
+ backgroundWorkspaceId?: string
+}
+
+/**
+ * The "Activity" tab for Manage Forks: a durable audit log of every fork, sync, and
+ * rollback as its own row (status, title, actor), each expanding to the timestamp and a
+ * collapsible per-kind breakdown of what changed. A loader shows while the current
+ * action runs.
+ */
+export function ForkActivityPanel({
+ pending = false,
+ pendingLabel = 'Working…',
+ backgroundWorkspaceId,
+}: ForkActivityPanelProps) {
+ const { data: jobs = [] } = useWorkspaceBackgroundWork(backgroundWorkspaceId)
+
+ if (!pending && jobs.length === 0) {
+ return (
+
+ Nothing here yet. Forks, syncs, and rollbacks will appear here.
+
+ )
+ }
+
+ return (
+
+ {pending ? (
+
+
+ {pendingLabel}
+
+ ) : null}
+
+ {jobs.length > 0 ?
: null}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal.tsx
new file mode 100644
index 00000000000..3ed9a6c7b64
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal.tsx
@@ -0,0 +1,443 @@
+'use client'
+
+import { useEffect, useId, useMemo, useState } from 'react'
+import {
+ Checkbox,
+ ChevronDown,
+ Chip,
+ ChipConfirmModal,
+ ChipCopyInput,
+ ChipInput,
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalFooter,
+ type ChipModalFooterSlotAction,
+ ChipModalHeader,
+ ChipModalTabs,
+ cn,
+ Tooltip,
+ toast,
+} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { Search } from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import type {
+ ForkCopyableResource,
+ GetForkResourcesResponse,
+} from '@/lib/api/contracts/workspace-fork'
+import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
+import { ForkActivityPanel } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel'
+import {
+ type ForkDirection,
+ useForkResources,
+ useForkWorkspace,
+ useRollbackFork,
+} from '@/hooks/queries/workspace-fork'
+
+interface ForkWorkspaceModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ sourceWorkspaceId: string
+ sourceWorkspaceName: string
+ /** The last sync into this workspace that can be undone (drives the rollback action). */
+ undoableRun: { otherWorkspaceId: string; otherName: string; direction: ForkDirection } | null
+ /** Whether the user is under their workspace cap; creating a fork is gated on this. */
+ canFork: boolean
+ /** Sends the user to upgrade (billing) when they try to fork at the cap. */
+ onUpgrade: () => void
+}
+
+type ResourceKey = Exclude
+type ResourceSelection = Record>
+
+const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [
+ { key: 'files', label: 'Files' },
+ { key: 'tables', label: 'Tables' },
+ { key: 'knowledgeBases', label: 'Knowledge bases' },
+ { key: 'customTools', label: 'Custom tools' },
+ { key: 'skills', label: 'Skills' },
+ { key: 'mcpServers', label: 'MCP servers' },
+]
+
+/** Show the inline search once a kind has more entries than fit comfortably. */
+const SEARCH_THRESHOLD = 8
+
+const emptySelection = (): ResourceSelection => ({
+ files: new Set(),
+ tables: new Set(),
+ knowledgeBases: new Set(),
+ customTools: new Set(),
+ skills: new Set(),
+ mcpServers: new Set(),
+})
+
+/**
+ * One expandable resource kind in the fork picker: a tri-state "select all" header
+ * (count of selected / total) plus, when expanded, a searchable scrollable list of
+ * individual resources so the user can copy a specific subset.
+ */
+function ResourceKindRow({
+ label,
+ items,
+ selected,
+ onToggleAll,
+ onToggleItem,
+ disabled,
+}: {
+ label: string
+ items: ForkCopyableResource[]
+ selected: Set
+ onToggleAll: (selectAll: boolean) => void
+ onToggleItem: (id: string, checked: boolean) => void
+ disabled: boolean
+}) {
+ const [expanded, setExpanded] = useState(false)
+ const [query, setQuery] = useState('')
+ const fieldId = useId()
+
+ const total = items.length
+ const selectedCount = selected.size
+ const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
+
+ const filtered = useMemo(() => {
+ const trimmed = query.trim().toLowerCase()
+ if (!trimmed) return items
+ return items.filter((item) => item.label.toLowerCase().includes(trimmed))
+ }, [items, query])
+
+ return (
+
+
+ onToggleAll(headerState !== true)}
+ disabled={disabled}
+ />
+ setExpanded((value) => !value)}
+ >
+
+ {label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
+
+
+
+
+
+ {expanded ? (
+
+ {total > SEARCH_THRESHOLD ? (
+
setQuery(event.target.value)}
+ placeholder={`Search ${label.toLowerCase()}`}
+ disabled={disabled}
+ />
+ ) : null}
+
+ {filtered.map((item) => {
+ const isChecked = selected.has(item.id)
+ const itemId = `${fieldId}-${item.id}`
+ return (
+
+ onToggleItem(item.id, checked === true)}
+ disabled={disabled}
+ />
+ {item.label}
+
+ )
+ })}
+ {filtered.length === 0 ? (
+
No matches
+ ) : null}
+
+
+ ) : null}
+
+ )
+}
+
+/**
+ * Names and creates a fork of the current workspace, lets the user pick which
+ * resources to copy (whole kinds or a specific subset), then navigates into the new
+ * fork. Unselected resources leave the corresponding workflow subblocks empty.
+ */
+export function ForkWorkspaceModal({
+ open,
+ onOpenChange,
+ sourceWorkspaceId,
+ sourceWorkspaceName,
+ undoableRun,
+ canFork,
+ onUpgrade,
+}: ForkWorkspaceModalProps) {
+ const router = useRouter()
+ const forkWorkspace = useForkWorkspace()
+ const rollback = useRollbackFork()
+ const resources = useForkResources(sourceWorkspaceId, open)
+ const [name, setName] = useState('')
+ const [selected, setSelected] = useState(emptySelection)
+ const [error, setError] = useState(null)
+
+ const [activeTab, setActiveTab] = useState<'config' | 'activity'>('config')
+ const [forkedWorkspace, setForkedWorkspace] = useState<{ id: string; name: string } | null>(null)
+ const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false)
+
+ useEffect(() => {
+ if (open) {
+ setName(`${sourceWorkspaceName} (fork)`)
+ setSelected(emptySelection())
+ setError(null)
+ setActiveTab('config')
+ setForkedWorkspace(null)
+ setConfirmRollbackOpen(false)
+ }
+ }, [open, sourceWorkspaceName])
+
+ const isForking = forkWorkspace.isPending
+
+ const availableKinds = useMemo(
+ () => RESOURCE_KINDS.filter((kind) => (resources.data?.[kind.key].length ?? 0) > 0),
+ [resources.data]
+ )
+
+ // A fork always produces a usable workspace: deployed workflows are copied, and
+ // when the source has none, create-fork seeds a blank starter workflow (plus any
+ // selected resources). So forking is never blocked - we just set expectations when
+ // there are no deployed workflows to carry over.
+ const noDeployedWorkflows =
+ Boolean(resources.data) && (resources.data?.deployedWorkflowCount ?? 0) === 0
+
+ const handleSubmit = () => {
+ // At a workspace cap, creating a fork is the only gated action - send the user to
+ // upgrade rather than blocking the whole modal (rollback / Activity stay reachable).
+ if (!canFork) {
+ onUpgrade()
+ return
+ }
+ const trimmed = name.trim()
+ if (!trimmed || isForking) return
+ setError(null)
+ const copy = resources.data
+ ? Object.fromEntries(RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])]))
+ : undefined
+ forkWorkspace.mutate(
+ { workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } },
+ {
+ onSuccess: (result) => {
+ toast.success(`Forked into "${result.workspace.name}"`)
+ setForkedWorkspace({ id: result.workspace.id, name: result.workspace.name })
+ setActiveTab('activity')
+ },
+ onError: (err) => setError(err.message || 'Failed to fork workspace'),
+ }
+ )
+ }
+
+ const openFork = () => {
+ if (!forkedWorkspace) return
+ onOpenChange(false)
+ router.push(`/workspace/${forkedWorkspace.id}/w`)
+ }
+
+ // Rollback undoes the last sync INTO this workspace, restoring each affected workflow
+ // to its prior deployed version. Lives in the Activity tab's footer.
+ const runRollback = async () => {
+ if (!undoableRun) return
+ try {
+ await rollback.mutateAsync({
+ workspaceId: sourceWorkspaceId,
+ body: { otherWorkspaceId: undoableRun.otherWorkspaceId },
+ })
+ toast.success(`Undid sync from "${undoableRun.otherName}"`)
+ setConfirmRollbackOpen(false)
+ setActiveTab('activity')
+ } catch (err) {
+ toast.error(getErrorMessage(err, 'Undo failed'))
+ }
+ }
+
+ const rollbackDisabled = rollback.isPending || !undoableRun
+ const rollbackTooltip = undoableRun
+ ? `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`
+ : 'No sync to roll back yet.'
+ const rollbackChip = (
+
+
+
+ setConfirmRollbackOpen(true)}
+ disabled={rollbackDisabled}
+ className={rollbackDisabled ? 'pointer-events-none' : undefined}
+ >
+ Rollback
+
+
+
+ {rollbackTooltip}
+
+ )
+ const rollbackAction: ChipModalFooterSlotAction[] =
+ activeTab === 'activity' && undoableRun ? [{ custom: rollbackChip }] : []
+
+ return (
+ <>
+
+ onOpenChange(false)}>Manage Forks
+
+ setActiveTab(value as 'config' | 'activity')}
+ className='mx-2'
+ />
+ {activeTab === 'activity' ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+ *
+
+ }
+ >
+ setName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
+ event.preventDefault()
+ handleSubmit()
+ }
+ }}
+ placeholder='Workspace name'
+ maxLength={100}
+ autoComplete='off'
+ disabled={isForking}
+ aria-label='Workspace name'
+ />
+
+
+ {availableKinds.length > 0 ? (
+
+
+ {availableKinds.map((kind) => (
+
+ setSelected((prev) => ({
+ ...prev,
+ [kind.key]: selectAll
+ ? new Set((resources.data?.[kind.key] ?? []).map((item) => item.id))
+ : new Set(),
+ }))
+ }
+ onToggleItem={(id, checked) =>
+ setSelected((prev) => {
+ const next = new Set(prev[kind.key])
+ if (checked) next.add(id)
+ else next.delete(id)
+ return { ...prev, [kind.key]: next }
+ })
+ }
+ disabled={isForking}
+ />
+ ))}
+
+ Unselected resources leave their workflow fields empty in the fork.
+
+
+
+ ) : null}
+
+ {noDeployedWorkflows ? (
+
+ No deployed workflows to copy — your fork will start with a blank workflow.
+
+ ) : null}
+
+ {error ?? undefined}
+ >
+ )}
+
+ onOpenChange(false)}
+ cancelDisabled={isForking}
+ secondaryActions={rollbackAction.length > 0 ? rollbackAction : undefined}
+ primaryAction={
+ activeTab === 'activity'
+ ? forkedWorkspace
+ ? { label: 'Open fork', onClick: openFork }
+ : { label: 'Done', onClick: () => onOpenChange(false) }
+ : {
+ label: isForking ? 'Forking...' : 'Fork',
+ onClick: handleSubmit,
+ // At the cap the button stays clickable (no name needed) so it can route to upgrade.
+ disabled: isForking || (canFork && !name.trim()),
+ }
+ }
+ />
+
+
+ void runRollback(),
+ pending: rollback.isPending,
+ pendingLabel: 'Rolling back...',
+ }}
+ />
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
index 5780f79532f..ba0539e5d88 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
@@ -1,8 +1,6 @@
'use client'
import { useCallback, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { useParams } from 'next/navigation'
import {
ChipModal,
ChipModalBody,
@@ -10,9 +8,12 @@ import {
ChipModalFooter,
ChipModalHeader,
toast,
-} from '@/components/emcn'
+} from '@sim/emcn'
+import { createLogger } from '@sim/logger'
+import { useParams } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
+import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
@@ -75,6 +76,10 @@ export function InviteModal({
const validateEmail = useCallback(
(email: string): string | null => {
+ const formatResult = quickValidateEmail(email)
+ if (!formatResult.isValid) {
+ return formatResult.reason ?? 'Invalid email'
+ }
if (workspacePermissions?.users?.some((user) => user.email === email)) {
return `${email} is already a teammate in this workspace`
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector.tsx
new file mode 100644
index 00000000000..b906d7cb858
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector.tsx
@@ -0,0 +1,71 @@
+'use client'
+
+import { useMemo } from 'react'
+import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
+import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
+import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
+
+interface DependentFieldSelectorProps {
+ selectorKey: SelectorKey
+ /** Full selector context, including the newly-chosen parent value. */
+ context: Record
+ /** False until the parent (credential/KB) target is chosen. */
+ enabled: boolean
+ value: string
+ onChange: (value: string) => void
+ title: string
+}
+
+/**
+ * A controlled, standalone selector for the sync modal's pre-sync reconfigure: fetches
+ * options via the shared selector data layer (the same `useSelectorOptions` registry the
+ * canvas selectors use) without the canvas store/blockId coupling. Mirrors
+ * {@link ConnectorSelectorField}.
+ */
+export function DependentFieldSelector({
+ selectorKey,
+ context,
+ enabled,
+ value,
+ onChange,
+ title,
+}: DependentFieldSelectorProps) {
+ const selectorContext = useMemo(() => {
+ const ctx: SelectorContext = {}
+ Object.assign(ctx, context)
+ return ctx
+ }, [context])
+
+ const { data: options = [], isLoading } = useSelectorOptions(selectorKey, {
+ context: selectorContext,
+ enabled,
+ })
+
+ const comboboxOptions = useMemo(
+ () => options.map((option) => ({ label: option.label, value: option.id })),
+ [options]
+ )
+
+ if (isLoading && enabled) {
+ return (
+
+
+ Loading…
+
+ )
+ }
+
+ return (
+ onChange(next)}
+ searchable
+ searchPlaceholder={`Search ${title.toLowerCase()}...`}
+ placeholder={`Select ${title.toLowerCase()}`}
+ disabled={!enabled}
+ emptyMessage={`No ${title.toLowerCase()} found`}
+ />
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx
new file mode 100644
index 00000000000..5c7c6eedba5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx
@@ -0,0 +1,267 @@
+'use client'
+
+import { type Dispatch, type SetStateAction, useMemo, useState } from 'react'
+import { ChevronDown, cn } from '@sim/emcn'
+import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
+import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
+import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector'
+import {
+ dependentKey,
+ effectiveDependentValue,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value'
+import type { SelectorKey } from '@/hooks/selectors/types'
+
+/** Stable empty array so a workflow with no dependents reuses one reference (no per-map alloc). */
+const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
+
+interface ReconfigBlock {
+ targetBlockId: string
+ blockName: string
+ fields: ForkDependentReconfig[]
+}
+
+/** Group a workflow's dependent fields by their block, sorted by block name. */
+function groupByBlock(fields: ForkDependentReconfig[]): ReconfigBlock[] {
+ const byBlock = new Map()
+ for (const field of fields) {
+ let block = byBlock.get(field.targetBlockId)
+ if (!block) {
+ block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
+ byBlock.set(field.targetBlockId, block)
+ }
+ block.fields.push(field)
+ }
+ return Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
+}
+
+interface ResourceReconfigureProps {
+ /** Every workflow this resource is used in (from the diff's `resourceUsages`). */
+ workflows: ForkResourceUsage['workflows']
+ /** This resource's dependent fields across all its workflows (from `dependentReconfigs`). */
+ dependents: ForkDependentReconfig[]
+ /** The chosen target id (credential/KB/table) the selectors query against. */
+ parentTargetValue: string
+ /** True when the target was changed in-session: start blank (the old value won't resolve). */
+ parentChanged: boolean
+ /**
+ * The target workspace the dependent selectors query against (direction-aware: the parent on
+ * push, the child on pull). Workspace-scoped selectors like `table.columns` and sim workflow
+ * pickers gate on it - the canvas supplies it from the active workspace, so the modal must too.
+ */
+ workspaceId: string
+ reconfig: Record
+ setReconfig: Dispatch>>
+}
+
+/**
+ * Always-on per-resource reconfigure listing: every workflow the resource is used in, each a
+ * chevron row that expands to its blocks + dependent selectors so the user can (re)configure
+ * them at any time - not only right after a target swap. A workflow with nothing configurable
+ * (a secret/file, or a credential with no dependent selector here) renders greyed and
+ * non-expandable with a tooltip, so the usage is still visible.
+ */
+export function ResourceReconfigure({
+ workflows,
+ dependents,
+ parentTargetValue,
+ parentChanged,
+ workspaceId,
+ reconfig,
+ setReconfig,
+}: ResourceReconfigureProps) {
+ // Group each workflow's dependents into blocks once per (workflows, dependents) change, so
+ // the grouping doesn't re-run on every parent re-render (setTargets / setReconfig fire often
+ // during the editing step). Bucket dependents by target workflow in a single pass first, so
+ // the per-workflow lookup is O(1) instead of a fresh `.filter` per workflow (O(W x D)).
+ const workflowBlocks = useMemo(() => {
+ const dependentsByWorkflow = new Map()
+ for (const dependent of dependents) {
+ const list = dependentsByWorkflow.get(dependent.targetWorkflowId)
+ if (list) list.push(dependent)
+ else dependentsByWorkflow.set(dependent.targetWorkflowId, [dependent])
+ }
+ return workflows.map((workflow) => ({
+ workflowId: workflow.workflowId,
+ workflowName: workflow.workflowName,
+ blocks: groupByBlock(dependentsByWorkflow.get(workflow.workflowId) ?? EMPTY_DEPENDENTS),
+ }))
+ }, [workflows, dependents])
+
+ if (workflows.length === 0) return null
+ return (
+
+
+
+ {workflowBlocks.map((workflow) => (
+
+ ))}
+
+
+
+ )
+}
+
+interface ReconfigWorkflowRowProps {
+ workflowName: string
+ blocks: ReconfigBlock[]
+ parentTargetValue: string
+ parentChanged: boolean
+ workspaceId: string
+ reconfig: Record
+ setReconfig: Dispatch>>
+}
+
+/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */
+function ReconfigWorkflowRow({
+ workflowName,
+ blocks,
+ parentTargetValue,
+ parentChanged,
+ workspaceId,
+ reconfig,
+ setReconfig,
+}: ReconfigWorkflowRowProps) {
+ // Auto-open a row that has a required dependent so it's visible without hunting through
+ // chevrons (a required field is what gates Sync). Deterministic from the block config at mount
+ // (lazy initializer, no effect/flicker); the user can still collapse it, and it won't reopen on
+ // re-render - only if the row remounts (its workflow changes).
+ const [open, setOpen] = useState(() =>
+ blocks.some((block) => block.fields.some((field) => field.required))
+ )
+ const configurable = blocks.length > 0
+
+ return (
+
+ {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed,
+ non-expandable row uses a native title tooltip to explain why. */}
+ setOpen((value) => !value)}
+ title={configurable ? undefined : 'Used here, but nothing to configure for this resource'}
+ className={cn(
+ 'flex w-full items-center gap-2 text-left text-sm transition-colors',
+ configurable
+ ? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
+ : 'cursor-default text-[var(--text-muted)]'
+ )}
+ >
+ {workflowName}
+
+
+ {configurable && open
+ ? blocks.map((block) => (
+
+ ))
+ : null}
+
+ )
+}
+
+interface BlockReconfigProps {
+ block: ReconfigBlock
+ parentTargetValue: string
+ parentChanged: boolean
+ workspaceId: string
+ reconfig: Record
+ setReconfig: Dispatch>>
+}
+
+/** One block card: its dependent selectors, chained so a parent feeds its in-block children. */
+function BlockReconfig({
+ block,
+ parentTargetValue,
+ parentChanged,
+ workspaceId,
+ reconfig,
+ setReconfig,
+}: BlockReconfigProps) {
+ // A field's effective value: the user's re-pick, else the stored value (stable parent) - but
+ // blank after a parent change, since the old value no longer resolves. Shared with the modal.
+ const effectiveValue = (field: ForkDependentReconfig) =>
+ effectiveDependentValue(field, reconfig, parentChanged)
+
+ // Chain re-picks: a field that provides a SelectorContext key feeds its effective value to
+ // its in-block descendants (a spreadsheet drives the sheet selector). Track only WHICH keys
+ // an in-block field provides (a Set) - the readiness check below tests membership, never a value.
+ const providedValues: Record = {}
+ const providedContextKeys = new Set()
+ for (const field of block.fields) {
+ if (field.providesContextKey) {
+ providedContextKeys.add(field.providesContextKey)
+ const value = effectiveValue(field)
+ if (value) providedValues[field.providesContextKey] = value
+ }
+ }
+
+ return (
+
+
{block.blockName}
+ {block.fields.map((field) => {
+ // Disabled until the parent target is set AND every in-block parent it depends on has
+ // a value, so a child never queries a stale upstream value.
+ const ready = field.consumesContextKeys.every(
+ (key) => !providedContextKeys.has(key) || providedValues[key] !== undefined
+ )
+ return (
+
+
+ {field.title}
+ {field.required ? * : null}
+
+
+ setReconfig((prev) => {
+ const nextState = { ...prev, [dependentKey(field)]: value }
+ // A changed parent invalidates its children's stale re-picks.
+ const providedKey = field.providesContextKey
+ if (providedKey) {
+ for (const sibling of block.fields) {
+ if (sibling.consumesContextKeys.includes(providedKey)) {
+ delete nextState[dependentKey(sibling)]
+ }
+ }
+ }
+ return nextState
+ })
+ }
+ title={field.title}
+ />
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value.ts
new file mode 100644
index 00000000000..653e9d1fdcd
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value.ts
@@ -0,0 +1,22 @@
+import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
+
+/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
+export function dependentKey(dependent: ForkDependentReconfig): string {
+ return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
+}
+
+/**
+ * The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
+ * stored value (`currentValue`). Blank when the parent target changed in-session, since the old
+ * stored value was for the previous parent and won't resolve against the new one. Shared by the
+ * modal (gate + payload) and the per-block selector so the rule can't drift between them.
+ */
+export function effectiveDependentValue(
+ field: ForkDependentReconfig,
+ reconfig: Record,
+ parentChanged: boolean
+): string {
+ const repicked = reconfig[dependentKey(field)]
+ if (repicked !== undefined) return repicked
+ return parentChanged ? '' : field.currentValue
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx
new file mode 100644
index 00000000000..0ffd90fb96c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx
@@ -0,0 +1,652 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import {
+ Badge,
+ ChipCombobox,
+ ChipConfirmModal,
+ ChipDropdown,
+ ChipModal,
+ ChipModalBody,
+ ChipModalFooter,
+ type ChipModalFooterSlotAction,
+ ChipModalHeader,
+ toast,
+} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { ArrowRight } from 'lucide-react'
+import type {
+ ForkDependentReconfig,
+ ForkLineageNodeApi,
+ ForkMappingEntry,
+ ForkResourceUsage,
+ ForkWorkflowChange,
+} from '@/lib/api/contracts/workspace-fork'
+import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
+import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure'
+import {
+ dependentKey,
+ effectiveDependentValue,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value'
+import {
+ type ForkDirection,
+ useForkDiff,
+ useForkMapping,
+ usePromoteFork,
+ useUpdateForkMapping,
+} from '@/hooks/queries/workspace-fork'
+
+interface PromoteWorkspaceModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceId: string
+ parent: ForkLineageNodeApi | null
+}
+
+const entryKey = (entry: ForkMappingEntry) => `${entry.kind}:${entry.sourceId}`
+
+/**
+ * Whether a mapping entry needs an in-place reconfigure: its effective target was changed
+ * in-session, or it's an unconfirmed suggestion (accepting it as-is still remaps + clears
+ * the dependents). Pure over (entry, in-session targets) so both the inline render and the
+ * Sync gate / override collection share one predicate instead of drifting copies.
+ */
+function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record): boolean {
+ const next = targets[entryKey(entry)] ?? entry.targetId ?? ''
+ if (next === '') return false
+ return entry.suggested || next !== (entry.targetId ?? '')
+}
+
+/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
+const EMPTY_TARGET_OWNERS: ReadonlyMap = new Map()
+
+/**
+ * Stable empty arrays so an entry with no usages/dependents keeps a constant prop reference,
+ * letting ResourceReconfigure's grouping memo skip recompute across the editing step's frequent
+ * re-renders.
+ */
+const EMPTY_USAGES: ForkResourceUsage['workflows'] = []
+const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
+
+/**
+ * Targets already taken by OTHER sources in the same kind, each mapped to the owning
+ * source's label (for a hint). Used to disable those targets on PUSH: a push row is unique
+ * on the parent (target) side, so a parent target can back only one source - a second source
+ * picking it would be silently dropped on save. Pull is the inverse (many parent sources may
+ * share one fork target, which resolves correctly), so pull passes the empty map and never
+ * disables. Excludes `exclude` so a source never disables its own current selection.
+ */
+function takenTargetOwners(
+ items: ForkMappingEntry[],
+ targets: Record,
+ exclude: ForkMappingEntry
+): Map {
+ const owners = new Map()
+ for (const item of items) {
+ if (entryKey(item) === entryKey(exclude)) continue
+ const target = targets[entryKey(item)] ?? item.targetId ?? ''
+ if (target !== '') owners.set(target, item.sourceLabel)
+ }
+ return owners
+}
+
+/** Section label + display order per mapping kind (one mapping step per kind). */
+const MAPPING_SECTION: Record = {
+ credential: { label: 'Credentials', order: 0 },
+ 'env-var': { label: 'Secrets', order: 1 },
+ table: { label: 'Tables', order: 2 },
+ 'knowledge-base': { label: 'Knowledge bases', order: 3 },
+ 'knowledge-document': { label: 'Knowledge documents', order: 4 },
+ file: { label: 'Files', order: 5 },
+ 'mcp-server': { label: 'MCP servers', order: 6 },
+ 'custom-tool': { label: 'Custom tools', order: 7 },
+ skill: { label: 'Skills', order: 8 },
+}
+
+interface EdgeOption {
+ value: string
+ label: string
+ otherWorkspaceId: string
+ direction: ForkDirection
+}
+
+/**
+ * Fork sync surface. Along the parent edge it force pushes/pulls: the overview
+ * picks a direction and lists each resource kind's mapping status, then Sync.
+ * "Edit mappings" steps through every kind (Back/Next, each source a
+ * settings-style section + full-width target) to set or review targets before
+ * landing back on Sync - which always confirms the overwrite first. The durable record of
+ * every sync is the Activity log in Manage Forks, so this modal just closes on
+ * success.
+ */
+export function PromoteWorkspaceModal({
+ open,
+ onOpenChange,
+ workspaceId,
+ parent,
+}: PromoteWorkspaceModalProps) {
+ // Sync is only ever performed along the parent edge (from a fork toward its
+ // parent). Child edges are intentionally not exposed here - a parent manages its
+ // forks (read-only list) rather than pushing/pulling into them.
+ const edgeOptions = useMemo(() => {
+ if (!parent) return []
+ return [
+ {
+ value: `push:${parent.id}`,
+ label: `Push to ${parent.name}`,
+ otherWorkspaceId: parent.id,
+ direction: 'push',
+ },
+ {
+ value: `pull:${parent.id}`,
+ label: `Pull from ${parent.name}`,
+ otherWorkspaceId: parent.id,
+ direction: 'pull',
+ },
+ ]
+ }, [parent])
+
+ const [selectedKey, setSelectedKey] = useState('')
+ // User's IN-SESSION mapping overrides only - NOT the source of truth. The
+ // displayed/persisted target falls back to each entry's stored `targetId`
+ // (see `targetFor`), so a reopened edge shows its remembered mappings even
+ // though React Query's structural sharing keeps `entries` referentially stable
+ // (a target-seeding effect gated on `entries` would never re-run there).
+ const [targets, setTargets] = useState>({})
+ // In-session re-picks for dependent fields whose parent the user swapped, keyed by
+ // `dependentKey`. Folded into the full effective set sent on sync, which promote persists as
+ // the stored mapping - so the selection survives every future sync without re-picking.
+ const [reconfig, setReconfig] = useState>({})
+ // Wizard step: 0 is the overview; 1..N edit one resource kind each, entered via
+ // "Edit mappings". Backing out of step 1 returns to the overview.
+ const [step, setStep] = useState(0)
+ const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+
+ useEffect(() => {
+ if (open) {
+ setSelectedKey(edgeOptions[0]?.value ?? '')
+ }
+ }, [open, edgeOptions])
+
+ // Restart at the overview and drop in-session overrides whenever it (re)opens or
+ // the direction changes - the mapping set, and therefore the steps, depend on the
+ // direction.
+ useEffect(() => {
+ setStep(0)
+ setTargets({})
+ setReconfig({})
+ }, [open, selectedKey])
+
+ const selected = edgeOptions.find((option) => option.value === selectedKey)
+ const otherWorkspaceId = selected?.otherWorkspaceId
+ const direction = selected?.direction ?? 'push'
+
+ const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled: open })
+ const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled: open })
+ const updateMapping = useUpdateForkMapping()
+ const promote = usePromoteFork()
+
+ const entries = useMemo(() => mapping.data?.entries ?? [], [mapping.data])
+ const dependentReconfigs = useMemo(
+ () => diff.data?.dependentReconfigs ?? [],
+ [diff.data?.dependentReconfigs]
+ )
+ const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages])
+
+ // Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a
+ // STABLE `dependents` array reference - a fresh `.filter` per render would defeat
+ // ResourceReconfigure's grouping memo.
+ const dependentsByParent = useMemo(() => {
+ const map = new Map()
+ for (const dependent of dependentReconfigs) {
+ const key = `${dependent.parentKind}:${dependent.parentSourceId}`
+ const list = map.get(key)
+ if (list) list.push(dependent)
+ else map.set(key, [dependent])
+ }
+ return map
+ }, [dependentReconfigs])
+
+ // Effective target for an entry: the user's in-session override if present,
+ // else the persisted mapping from the server. Read directly from `entries` so
+ // a reopened edge reflects stored mappings without a seeding effect.
+ const targetFor = (entry: ForkMappingEntry) => targets[entryKey(entry)] ?? entry.targetId ?? ''
+
+ const requiredComplete = entries.every((entry) => !entry.required || targetFor(entry) !== '')
+
+ // Every workflow a mapping entry's resource is used in, for the always-on reconfigure
+ // listing rendered beneath that mapping (so the credential/KB stays in context).
+ const usagesForEntry = (entry: ForkMappingEntry): ForkResourceUsage['workflows'] =>
+ resourceUsages.find(
+ (usage) => usage.parentKind === entry.kind && usage.parentSourceId === entry.sourceId
+ )?.workflows ?? EMPTY_USAGES
+
+ // This entry's dependent fields (its credential/KB's selectors), from the memoized grouping.
+ const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
+ dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
+
+ // Group mappings by resource type - one step per kind, required types first.
+ const groupedEntries = useMemo(() => {
+ const groups = new Map()
+ for (const entry of entries) {
+ const list = groups.get(entry.kind)
+ if (list) list.push(entry)
+ else groups.set(entry.kind, [entry])
+ }
+ return Array.from(groups, ([kind, items]) => ({
+ kind,
+ label: MAPPING_SECTION[kind].label,
+ items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)),
+ })).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order)
+ }, [entries])
+
+ // The mapping entry each dependent hangs off, indexed by `kind:sourceId` (matching `entryKey`)
+ // so the per-field lookups below are O(1) instead of rescanning `entries` for every dependent -
+ // and several times per field across the Sync gate, the value helper, and the payload build.
+ const entriesByParent = useMemo(() => {
+ const map = new Map()
+ for (const entry of entries) map.set(entryKey(entry), entry)
+ return map
+ }, [entries])
+
+ // The mapping entry a dependent field hangs off (its credential/KB), for change + target lookups.
+ const entryForDependent = (field: ForkDependentReconfig) =>
+ entriesByParent.get(`${field.parentKind}:${field.parentSourceId}`)
+
+ // The value sent + displayed for a dependent (delegates to the shared rule): the user's
+ // re-pick, else the stored value - blank when this field's parent target changed in-session.
+ // Callers that already resolved the parent pass it in to skip a second lookup.
+ const dependentValueFor = (
+ field: ForkDependentReconfig,
+ parent = entryForDependent(field)
+ ): string =>
+ effectiveDependentValue(
+ field,
+ reconfig,
+ parent ? shouldReconfigureEntry(parent, targets) : false
+ )
+
+ // Every required dependent whose parent IS mapped must have a value before sync. A dependent
+ // whose parent target is still empty can't be picked yet (its selector is disabled) and is
+ // gated by `requiredComplete` on the parent instead, so it's skipped here.
+ const reconfigComplete = dependentReconfigs.every((field) => {
+ if (!field.required) return true
+ const parent = entryForDependent(field)
+ if (!parent || targetFor(parent) === '') return true
+ return dependentValueFor(field, parent) !== ''
+ })
+
+ // Per-kind status for the overview listing: "Fully mapped" or "n/total mapped",
+ // flagged when a REQUIRED target is still missing (which blocks Sync). Reads the
+ // effective (override-or-persisted) target so it reflects both remembered mappings
+ // and in-session edits.
+ const kindSummaries = groupedEntries.map((group) => {
+ const total = group.items.length
+ const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
+ const requiredPending = group.items.some((entry) => entry.required && targetFor(entry) === '')
+ return { kind: group.kind, label: group.label, total, mapped, requiredPending }
+ })
+
+ // Step 0 is the overview; each subsequent step edits one resource kind, entered via
+ // "Edit mappings". Reconfigure cards render inline under the changed mapping (not as
+ // their own steps) so the credential/KB context stays visible. `safeStep` guards
+ // against a group count that shrank on refetch.
+ const stepCount = 1 + groupedEntries.length
+ const safeStep = Math.min(step, Math.max(0, stepCount - 1))
+ const isLastStep = safeStep >= stepCount - 1
+ const currentGroup = safeStep >= 1 ? (groupedEntries[safeStep - 1] ?? null) : null
+ // Gate Sync on the diff being loaded too, not just the mapping: until `diff.data` arrives
+ // `dependentReconfigs` is empty, so `reconfigComplete` is vacuously true and `runPromote` would
+ // omit `dependentValues` - i.e. Sync before the diff loads would bypass dependent gating.
+ const syncDisabled =
+ submitting ||
+ !otherWorkspaceId ||
+ !requiredComplete ||
+ !reconfigComplete ||
+ mapping.isLoading ||
+ !diff.data
+ const headsUp =
+ (diff.data?.mcpReauthServerIds.length ?? 0) > 0 ||
+ (diff.data?.inlineSecretSources.length ?? 0) > 0
+
+ const runPromote = async () => {
+ if (!otherWorkspaceId) return
+ setSubmitting(true)
+ try {
+ await updateMapping.mutateAsync({
+ workspaceId,
+ body: {
+ otherWorkspaceId,
+ direction,
+ entries: entries.map((entry) => ({
+ resourceType: entry.resourceType,
+ sourceId: entry.sourceId,
+ targetId: targetFor(entry) || null,
+ })),
+ },
+ })
+
+ // Send the full stored mapping for every dependent whose parent is mapped (its effective
+ // value - re-pick, stored, or blank-after-change). Promote persists this verbatim as the
+ // stored mapping and applies it; fields whose parent isn't mapped yet are omitted (they
+ // can't be configured). This is the whole "what's in the mapping goes in" contract.
+ const dependentValues = dependentReconfigs.flatMap((field) => {
+ const parent = entryForDependent(field)
+ if (!parent || targetFor(parent) === '') return []
+ return [
+ {
+ workflowId: field.targetWorkflowId,
+ blockId: field.targetBlockId,
+ subBlockKey: field.subBlockKey,
+ value: dependentValueFor(field, parent),
+ },
+ ]
+ })
+
+ const result = await promote.mutateAsync({
+ workspaceId,
+ body: {
+ otherWorkspaceId,
+ direction,
+ // Once the diff has loaded, ALWAYS send the full effective set - including `[]`, which
+ // means "every dependent went away" and must reconcile/clear the live replace targets'
+ // stored rows. Collapsing `[]` into omission would make the backend PRESERVE stale rows.
+ // Only omit before the diff loads (set unknown), so the existing store is left untouched.
+ ...(diff.data ? { dependentValues } : {}),
+ },
+ })
+
+ if (!result.promoteRunId) {
+ if (result.unmappedRequired.length > 0) {
+ toast.error('Map all required credentials and secrets first')
+ return
+ }
+ toast.error('Sync did not complete')
+ return
+ }
+
+ const target = parent?.name ?? 'the workspace'
+ const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
+ const needsConfig = result.needsConfiguration
+ const clearedOptional = result.clearedOptional
+ // List the affected blocks, naming the workflow for a single one and falling back to
+ // a count across many. Block names ("Gmail 2") are far more actionable than the
+ // generic field titles ("Label") behind them.
+ const formatWhere = (list: Array<{ workflowName: string; blocks: string[] }>) => {
+ const totalBlocks = list.reduce((sum, workflow) => sum + workflow.blocks.length, 0)
+ if (list.length === 1) return `${list[0].blocks.join(', ')} in ${list[0].workflowName}`
+ return `${totalBlocks} block${totalBlocks === 1 ? '' : 's'} across ${list.length} workflows`
+ }
+ const optionalBlocks = clearedOptional.reduce(
+ (sum, workflow) => sum + workflow.blocks.length,
+ 0
+ )
+ // Appended to a higher-priority warning so a cleared optional filter is never hidden.
+ const optionalSuffix =
+ optionalBlocks > 0
+ ? ` (+${optionalBlocks} block${optionalBlocks === 1 ? '' : 's'} with optional fields cleared)`
+ : ''
+ if (needsConfig.length > 0) {
+ toast.warning(`${label}. Re-check ${formatWhere(needsConfig)}.${optionalSuffix}`)
+ } else if (result.deployFailed > 0) {
+ const n = result.deployFailed
+ toast.warning(
+ `${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.${optionalSuffix}`
+ )
+ } else if (clearedOptional.length > 0) {
+ toast.warning(
+ `${label}. Optional settings cleared — re-check ${formatWhere(clearedOptional)}.`
+ )
+ } else {
+ toast.success(label)
+ }
+ onOpenChange(false)
+ } catch (error) {
+ toast.error(getErrorMessage(error, 'Sync failed'))
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const workflowChanges = useMemo(() => {
+ const order: Record = { update: 0, create: 1, archive: 2 }
+ return [...(diff.data?.workflows ?? [])].sort(
+ (a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName)
+ )
+ }, [diff.data?.workflows])
+
+ // Right-cluster action sitting immediately left of the primary. The overview pairs
+ // "Edit mappings" with Sync (entering the step walk); every editing step pairs Back
+ // with Next (or with Sync on the last step). Back out of step 1 lands on the
+ // overview, restoring the "Edit mappings · Sync" pair.
+ const syncPrimaryAdjacent: ChipModalFooterSlotAction | undefined =
+ safeStep === 0
+ ? groupedEntries.length > 0
+ ? { label: 'Edit mappings', onClick: () => setStep(1), disabled: submitting }
+ : undefined
+ : { label: 'Back', onClick: () => setStep(safeStep - 1), disabled: submitting }
+
+ return (
+ <>
+
+ onOpenChange(false)}>
+ {currentGroup ? `Sync workspace: ${currentGroup.label}` : 'Sync workspace'}
+
+
+ {safeStep === 0 ? (
+
+
+
+
+
+ {/* Always shown once the diff loads so the user sees the section even with nothing
+ deployed - an empty change list means the source has no deployed workflows (every
+ deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
+ {diff.data ? (
+
+ {workflowChanges.length > 0 ? (
+
+ {workflowChanges.map((change, index) => {
+ const renamed = change.currentName !== change.otherName
+ return (
+
+
+ {change.currentName}
+
+ {renamed ? (
+ <>
+
+
+ {change.otherName}
+
+ >
+ ) : null}
+
+ )
+ })}
+
+ ) : (
+
+ {direction === 'push'
+ ? `No deployed workflows. Deploy workflows to push changes to ${parent?.name ?? 'the parent'}.`
+ : `No deployed workflows in ${parent?.name ?? 'the parent'} to pull.`}
+
+ )}
+
+ ) : null}
+
+ {headsUp ? (
+
+ {(diff.data?.mcpReauthServerIds.length ?? 0) > 0 ? (
+
+ {diff.data?.mcpReauthServerIds.length} MCP server(s) use OAuth and must be
+ re-authorized in the target workspace.
+
+ ) : null}
+ {(diff.data?.inlineSecretSources.length ?? 0) > 0 ? (
+
+ {diff.data?.inlineSecretSources.length} inline secret(s) can't be auto-mapped
+ — set them in the target workspace.
+
+ ) : null}
+
+ ) : null}
+
+ {kindSummaries.length > 0 ? (
+
+
+ {kindSummaries.map(({ kind, label, total, mapped, requiredPending }) => {
+ const complete = mapped === total
+ return (
+
+ {label}
+
+ {complete ? 'Fully mapped' : `${mapped}/${total} mapped`}
+
+
+ )
+ })}
+
+
+ ) : null}
+
+ ) : currentGroup ? (
+
+ {currentGroup.items.map((entry) => {
+ // On push, a parent target can back only one source; disable any target
+ // another source already took (named in the hint) so the user can't create a
+ // mapping that would be silently dropped on save. Pull allows sharing a target.
+ const takenOwners =
+ direction === 'push'
+ ? takenTargetOwners(currentGroup.items, targets, entry)
+ : EMPTY_TARGET_OWNERS
+ return (
+
+ *
+
+ ) : undefined
+ }
+ >
+ {
+ const owner = takenOwners.get(candidate.id)
+ return {
+ label: owner
+ ? `${candidate.label} · mapped to ${owner}`
+ : candidate.label,
+ value: candidate.id,
+ disabled: owner !== undefined,
+ }
+ })}
+ value={targetFor(entry) || undefined}
+ onChange={(value) => {
+ setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
+ // Changing the parent invalidates any in-session re-picks of its
+ // dependents - they were chosen against the old account and won't resolve
+ // against the new one, so drop them; otherwise a stale re-pick (which
+ // wins over the parent-changed check) would be sent to the new account.
+ setReconfig((prev) => {
+ let changed = false
+ const next = { ...prev }
+ for (const dependent of dependentsForEntry(entry)) {
+ const key = dependentKey(dependent)
+ if (key in next) {
+ delete next[key]
+ changed = true
+ }
+ }
+ return changed ? next : prev
+ })
+ }}
+ placeholder='Select target'
+ />
+ {entry.candidatesTruncated ? (
+
+ This workspace has more options than shown here. If you don't see the right
+ one, narrow it down by name.
+
+ ) : null}
+ {/* Always-on: every workflow this resource is used in, each expandable to
+ its blocks + dependent selectors (greyed when nothing to configure). */}
+
+
+ )
+ })}
+
+ ) : null}
+
+ onOpenChange(false)}
+ hideCancel
+ primaryAdjacentAction={syncPrimaryAdjacent}
+ primaryAction={
+ safeStep >= 1 && !isLastStep
+ ? { label: 'Next', onClick: () => setStep(safeStep + 1), disabled: submitting }
+ : {
+ label: submitting ? 'Working...' : 'Sync',
+ onClick: () => setConfirmSyncOpen(true),
+ disabled: syncDisabled,
+ disabledTooltip: !requiredComplete
+ ? 'Map all required secrets first'
+ : !reconfigComplete
+ ? 'Reconfigure all required fields first'
+ : undefined,
+ }
+ }
+ />
+
+
+ {
+ setConfirmSyncOpen(false)
+ void runPromote()
+ },
+ pending: submitting,
+ pendingLabel: 'Syncing...',
+ }}
+ />
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available.ts
new file mode 100644
index 00000000000..8231e7b6735
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available.ts
@@ -0,0 +1,28 @@
+import { getSubscriptionAccessState } from '@/lib/billing/client'
+import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { useWorkspaceOwnerBilling } from '@/hooks/queries/workspace'
+
+const isBillingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
+const isForkingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_FORKING_ENABLED'))
+
+/**
+ * Client mirror of the server fork EE gate (`assertForkingEnabled`): on Sim Cloud
+ * the active workspace's billed account (its owner's rolled-up plan) must be
+ * Enterprise; on self-hosted it's the `NEXT_PUBLIC_FORKING_ENABLED` override. Used
+ * to hide the fork UI (and skip the lineage query) for workspaces that cannot fork.
+ *
+ * Gating on the WORKSPACE's plan (not the viewer's) is what matches the server,
+ * which checks the workspace org's plan: a viewer who belongs to a different
+ * Enterprise org no longer sees fork UI on a non-Enterprise workspace, and a
+ * member of an Enterprise workspace isn't denied it just because their own
+ * highest plan is lower. The server gate remains the security boundary.
+ *
+ * Self-hosted relies on `NEXT_PUBLIC_FORKING_ENABLED` / `NEXT_PUBLIC_BILLING_ENABLED`
+ * mirroring the server's `FORKING_ENABLED` / `BILLING_ENABLED`; set each pair
+ * together or the UI and API will disagree.
+ */
+export function useForkingAvailable(workspaceId?: string): boolean {
+ const { data } = useWorkspaceOwnerBilling(isBillingEnabledClient ? workspaceId : undefined)
+ if (!isBillingEnabledClient) return isForkingEnabledClient
+ return getSubscriptionAccessState(data).hasUsableEnterpriseAccess
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index 499f79b91b2..1a0b9718eb7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -1,13 +1,12 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { MoreHorizontal } from 'lucide-react'
import {
ChevronDown,
Chip,
ChipConfirmModal,
chipVariants,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
@@ -15,15 +14,21 @@ import {
Plus,
Send,
Skeleton,
-} from '@/components/emcn'
-import { ManageWorkspace, PanelLeft } from '@/components/emcn/icons'
-import { cn } from '@/lib/core/utils/cn'
+} from '@sim/emcn'
+import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { MoreHorizontal } from 'lucide-react'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
+import { ForkWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
+import { PromoteWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal'
+import { useForkingAvailable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available'
import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
+import { useForkLineage } from '@/hooks/queries/workspace-fork'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -96,6 +101,8 @@ function WorkspaceHeaderImpl({
}: WorkspaceHeaderProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
+ const [isForkModalOpen, setIsForkModalOpen] = useState(false)
+ const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null)
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false)
@@ -120,6 +127,14 @@ function WorkspaceHeaderImpl({
}, [])
const { navigateToSettings } = useSettingsNavigation()
+ const forkingAvailable = useForkingAvailable(workspaceId)
+ const { canAdmin } = useUserPermissionsContext()
+ // Forking and sync rewrite workflow state and deployments en masse, so they are
+ // workspace-admin only (org owners/admins derive workspace admin server-side via
+ // the resolved viewer permission). Every fork route re-checks this; gating the
+ // entry points here just keeps the UI honest. The server remains the boundary.
+ const canUseForking = forkingAvailable && canAdmin
+ const { data: forkLineage } = useForkLineage(workspaceId, canUseForking)
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null
@@ -238,6 +253,16 @@ function WorkspaceHeaderImpl({
onUploadLogo(capturedWorkspaceRef.current.id)
}
+ // Always open Manage Forks - rollback and the durable Activity log live here and must
+ // stay reachable at a workspace cap. Only creating a NEW fork is gated (in the modal).
+ const handleForkAction = () => {
+ setIsForkModalOpen(true)
+ }
+
+ const handleSyncAction = () => {
+ setIsPromoteModalOpen(true)
+ }
+
/**
* Handle leave workspace after confirmation
*/
@@ -635,6 +660,9 @@ function WorkspaceHeaderImpl({
const contextCanAdmin = capturedPermissions === 'admin'
const capturedWorkspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
const isOwner = capturedWorkspace && sessionUserId === capturedWorkspace.ownerId
+ // Only the active row can offer fork actions: its lineage/availability is the
+ // data loaded for `workspaceId`. Sync needs a parent; Manage needs children.
+ const showForkInContext = capturedWorkspaceRef.current?.id === workspaceId && canUseForking
return (
+ {
+ if (isBillingEnabled) navigateToSettings({ section: 'billing' })
+ }}
+ />
+
setIsDeleteModalOpen(false)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index a4c21f84dc9..2e94f37a861 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -1,16 +1,12 @@
'use client'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { MoreHorizontal, Pin } from 'lucide-react'
-import Link from 'next/link'
-import { useParams, usePathname, useRouter } from 'next/navigation'
-import { usePostHog } from 'posthog-js/react'
import {
Button,
Chip,
ChipLink,
chipVariants,
+ cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -22,7 +18,7 @@ import {
Skeleton,
Tooltip,
Upload,
-} from '@/components/emcn'
+} from '@sim/emcn'
import {
BookOpen,
Calendar,
@@ -37,10 +33,14 @@ import {
Table,
Task,
Workflow,
-} from '@/components/emcn/icons'
+} from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { MoreHorizontal, Pin } from 'lucide-react'
+import Link from 'next/link'
+import { useParams, usePathname, useRouter } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
import { useSession } from '@/lib/auth/auth-client'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
-import { cn } from '@/lib/core/utils/cn'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree, getFolderPath } from '@/lib/folders/tree'
import { captureEvent } from '@/lib/posthog/client'
diff --git a/apps/sim/background/a2a-push-notification-delivery.ts b/apps/sim/background/a2a-push-notification-delivery.ts
deleted file mode 100644
index 52c29aeb9af..00000000000
--- a/apps/sim/background/a2a-push-notification-delivery.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { TaskState } from '@a2a-js/sdk'
-import { createLogger } from '@sim/logger'
-import { task } from '@trigger.dev/sdk'
-import { deliverPushNotification } from '@/lib/a2a/push-notifications'
-
-const logger = createLogger('A2APushNotificationDelivery')
-
-export interface A2APushNotificationParams {
- taskId: string
- state: TaskState
-}
-
-export const a2aPushNotificationTask = task({
- id: 'a2a-push-notification-delivery',
- retry: {
- maxAttempts: 5,
- minTimeoutInMs: 1000,
- maxTimeoutInMs: 60000,
- factor: 2,
- },
- run: async (params: A2APushNotificationParams) => {
- logger.info('Delivering A2A push notification', params)
-
- const success = await deliverPushNotification(params.taskId, params.state)
-
- if (!success) {
- throw new Error(`Failed to deliver push notification for task ${params.taskId}`)
- }
-
- logger.info('A2A push notification delivered successfully', params)
- return { success: true, taskId: params.taskId, state: params.state }
- },
-})
diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts
index cada41fdf3a..a6ede324481 100644
--- a/apps/sim/background/cleanup-soft-deletes.test.ts
+++ b/apps/sim/background/cleanup-soft-deletes.test.ts
@@ -51,7 +51,6 @@ vi.mock('@sim/db/schema', () => {
const wsFileCols = ['id', 'key', 'context', 'workspaceId', 'deletedAt', 'uploadedAt']
const softCols = ['id', 'archivedAt', 'deletedAt', 'workspaceId']
return {
- a2aAgent: table(softCols),
copilotChats: table(['id', 'workflowId']),
document: table(['id', 'storageKey', 'knowledgeBaseId']),
knowledgeBase: table(softCols),
diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts
index ae900abc804..ee01e297898 100644
--- a/apps/sim/background/cleanup-soft-deletes.ts
+++ b/apps/sim/background/cleanup-soft-deletes.ts
@@ -1,6 +1,5 @@
import { db } from '@sim/db'
import {
- a2aAgent,
copilotChats,
document,
knowledgeBase,
@@ -167,12 +166,6 @@ const CLEANUP_TARGETS = [
wsCol: workflowMcpServer.workspaceId,
name: 'workflowMcpServer',
},
- {
- table: a2aAgent,
- softDeleteCol: a2aAgent.archivedAt,
- wsCol: a2aAgent.workspaceId,
- name: 'a2aAgent',
- },
] as const
/**
diff --git a/apps/sim/background/fork-content-copy.ts b/apps/sim/background/fork-content-copy.ts
new file mode 100644
index 00000000000..836b387d48e
--- /dev/null
+++ b/apps/sim/background/fork-content-copy.ts
@@ -0,0 +1,25 @@
+import { task } from '@trigger.dev/sdk'
+import {
+ type ForkContentCopyPayload,
+ runForkContentCopy,
+} from '@/lib/workspaces/fork/copy/content-copy-runner'
+
+/**
+ * Trigger.dev wrapper for the post-fork heavy-content copy (table rows, KB
+ * documents + embeddings, file blobs). Backgrounding keeps the fork request fast
+ * and lets the copy survive app deploys. `maxAttempts: 1` — the copy is
+ * non-transactional best-effort (per-row inserts with fresh ids), so a blind
+ * re-run would duplicate rows; a partial failure simply leaves the fork's content
+ * incomplete (the workflows themselves committed synchronously).
+ */
+export const forkContentCopyTask = task({
+ id: 'fork-content-copy',
+ retry: { maxAttempts: 1 },
+ queue: {
+ name: 'fork-content-copy',
+ concurrencyLimit: 10,
+ },
+ run: async (payload: ForkContentCopyPayload) => {
+ await runForkContentCopy(payload)
+ },
+})
diff --git a/apps/sim/blocks/blocks/a2a.ts b/apps/sim/blocks/blocks/a2a.ts
index b15e8dacfd4..c2e563d4dda 100644
--- a/apps/sim/blocks/blocks/a2a.ts
+++ b/apps/sim/blocks/blocks/a2a.ts
@@ -1,72 +1,21 @@
import { A2AIcon } from '@/components/icons'
-import type { BlockConfig } from '@/blocks/types'
-import { IntegrationType } from '@/blocks/types'
-import { normalizeFileInput } from '@/blocks/utils'
-import type { ToolResponse } from '@/tools/types'
+import { type BlockConfig, IntegrationType } from '@/blocks/types'
+import { normalizeFileInput, parseOptionalNumberInput } from '@/blocks/utils'
-export interface A2AResponse extends ToolResponse {
- output: {
- /** Response content from the agent */
- content?: string
- /** Task ID */
- taskId?: string
- /** Context ID for conversation continuity */
- contextId?: string
- /** Task state */
- state?: string
- /** Structured output artifacts */
- artifacts?: Array<{
- name?: string
- description?: string
- parts: Array<{ kind: string; text?: string; data?: unknown }>
- }>
- /** Full message history */
- history?: Array<{
- role: 'user' | 'agent'
- parts: Array<{ kind: string; text?: string }>
- }>
- /** Whether cancellation was successful (cancel_task) */
- cancelled?: boolean
- /** Whether task is still running (resubscribe) */
- isRunning?: boolean
- /** Agent name (get_agent_card) */
- name?: string
- /** Agent description (get_agent_card) */
- description?: string
- /** Agent URL (get_agent_card) */
- url?: string
- /** Agent version (get_agent_card) */
- version?: string
- /** Agent capabilities (get_agent_card) */
- capabilities?: Record
- /** Agent skills (get_agent_card) */
- skills?: Array<{ id: string; name: string; description?: string }>
- /** Agent authentication schemes (get_agent_card) */
- authentication?: { schemes: string[] }
- /** Push notification webhook URL */
- webhookUrl?: string
- /** Push notification token */
- token?: string
- /** Whether push notification config exists */
- exists?: boolean
- /** Operation success indicator */
- success?: boolean
- }
-}
-
-export const A2ABlock: BlockConfig = {
+export const A2ABlock: BlockConfig = {
type: 'a2a',
name: 'A2A',
description: 'Interact with external A2A-compatible agents',
longDescription:
- 'Use the A2A (Agent-to-Agent) protocol to interact with external AI agents. ' +
- 'Send messages, query task status, cancel tasks, or discover agent capabilities. ' +
- 'Compatible with any A2A-compliant agent including LangGraph, Google ADK, and other Sim workflows.',
- docsLink: 'https://docs.sim.ai/workflows/blocks/a2a',
+ 'Use the A2A (Agent-to-Agent) protocol to call external AI agents. Send messages, ' +
+ 'track or cancel tasks, and discover an agent\u2019s capabilities via its Agent Card. ' +
+ 'Compatible with any A2A-compliant agent.',
+ docsLink: 'https://docs.sim.ai/integrations/a2a',
category: 'blocks',
integrationType: IntegrationType.DevOps,
bgColor: '#4151B5',
icon: A2AIcon,
+
subBlocks: [
{
id: 'operation',
@@ -77,10 +26,6 @@ export const A2ABlock: BlockConfig = {
{ label: 'Get Task', id: 'a2a_get_task' },
{ label: 'Cancel Task', id: 'a2a_cancel_task' },
{ label: 'Get Agent Card', id: 'a2a_get_agent_card' },
- { label: 'Resubscribe', id: 'a2a_resubscribe' },
- { label: 'Set Push Notification', id: 'a2a_set_push_notification' },
- { label: 'Get Push Notification', id: 'a2a_get_push_notification' },
- { label: 'Delete Push Notification', id: 'a2a_delete_push_notification' },
],
defaultValue: 'a2a_send_message',
},
@@ -88,9 +33,9 @@ export const A2ABlock: BlockConfig = {
id: 'agentUrl',
title: 'Agent URL',
type: 'short-input',
- placeholder: 'https://api.example.com/a2a/serve/agent-id',
- required: true,
+ placeholder: 'https://api.example.com/a2a',
description: 'The A2A endpoint URL',
+ required: true,
},
{
id: 'message',
@@ -99,14 +44,15 @@ export const A2ABlock: BlockConfig = {
placeholder: 'Enter your message to the agent...',
description: 'The message to send to the agent',
condition: { field: 'operation', value: 'a2a_send_message' },
- required: true,
+ required: { field: 'operation', value: 'a2a_send_message' },
},
{
id: 'data',
title: 'Data (JSON)',
type: 'code',
+ language: 'json',
placeholder: '{\n "key": "value"\n}',
- description: 'Structured data to include with the message (DataPart)',
+ description: 'Optional structured data to include with the message',
condition: { field: 'operation', value: 'a2a_send_message' },
},
{
@@ -115,7 +61,7 @@ export const A2ABlock: BlockConfig = {
type: 'file-upload',
canonicalParamId: 'files',
placeholder: 'Upload files to send',
- description: 'Files to include with the message (FilePart)',
+ description: 'Optional files to include with the message',
condition: { field: 'operation', value: 'a2a_send_message' },
mode: 'basic',
multiple: true,
@@ -126,7 +72,16 @@ export const A2ABlock: BlockConfig = {
type: 'short-input',
canonicalParamId: 'files',
placeholder: 'Reference files from previous blocks',
- description: 'Files to include with the message (FilePart)',
+ description: 'Optional files to include with the message',
+ condition: { field: 'operation', value: 'a2a_send_message' },
+ mode: 'advanced',
+ },
+ {
+ id: 'contextId',
+ title: 'Context ID',
+ type: 'short-input',
+ placeholder: 'Optional - for multi-turn conversations',
+ description: 'Context ID for conversation continuity',
condition: { field: 'operation', value: 'a2a_send_message' },
mode: 'advanced',
},
@@ -135,38 +90,12 @@ export const A2ABlock: BlockConfig = {
title: 'Task ID',
type: 'short-input',
placeholder: 'Task ID',
- description: 'Task ID to query, cancel, continue, or configure',
+ description: 'Task to continue, query, or cancel',
condition: {
field: 'operation',
- value: [
- 'a2a_send_message',
- 'a2a_get_task',
- 'a2a_cancel_task',
- 'a2a_resubscribe',
- 'a2a_set_push_notification',
- 'a2a_get_push_notification',
- 'a2a_delete_push_notification',
- ],
- },
- required: {
- field: 'operation',
- value: [
- 'a2a_get_task',
- 'a2a_cancel_task',
- 'a2a_resubscribe',
- 'a2a_set_push_notification',
- 'a2a_get_push_notification',
- 'a2a_delete_push_notification',
- ],
+ value: ['a2a_send_message', 'a2a_get_task', 'a2a_cancel_task'],
},
- },
- {
- id: 'contextId',
- title: 'Context ID',
- type: 'short-input',
- placeholder: 'Optional - for multi-turn conversations',
- description: 'Context ID for conversation continuity across tasks',
- condition: { field: 'operation', value: 'a2a_send_message' },
+ required: { field: 'operation', value: ['a2a_get_task', 'a2a_cancel_task'] },
},
{
id: 'historyLength',
@@ -176,179 +105,64 @@ export const A2ABlock: BlockConfig = {
description: 'Number of history messages to include in the response',
condition: { field: 'operation', value: 'a2a_get_task' },
},
- {
- id: 'webhookUrl',
- title: 'Webhook URL',
- type: 'short-input',
- placeholder: 'https://your-app.com/webhook',
- description: 'HTTPS webhook URL to receive task update notifications',
- condition: { field: 'operation', value: 'a2a_set_push_notification' },
- required: { field: 'operation', value: 'a2a_set_push_notification' },
- },
- {
- id: 'token',
- title: 'Webhook Token',
- type: 'short-input',
- password: true,
- placeholder: 'Optional token for webhook validation',
- description: 'Token that will be included in webhook requests for validation',
- condition: { field: 'operation', value: 'a2a_set_push_notification' },
- },
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
password: true,
placeholder: 'Optional API key for authenticated agents',
- description:
- 'Optional API key sent via X-API-Key header for agents that require authentication',
+ description: 'Sent via the X-API-Key header for agents that require authentication',
},
],
+
tools: {
- access: [
- 'a2a_send_message',
- 'a2a_get_task',
- 'a2a_cancel_task',
- 'a2a_get_agent_card',
- 'a2a_resubscribe',
- 'a2a_set_push_notification',
- 'a2a_get_push_notification',
- 'a2a_delete_push_notification',
- ],
+ access: ['a2a_send_message', 'a2a_get_task', 'a2a_cancel_task', 'a2a_get_agent_card'],
config: {
tool: (params) => params.operation as string,
params: (params) => {
- const { files, ...rest } = params
+ const { files, historyLength, ...rest } = params
const normalizedFiles = normalizeFileInput(files)
+ const parsedHistoryLength = parseOptionalNumberInput(historyLength, 'History Length', {
+ integer: true,
+ min: 1,
+ max: 1000,
+ })
return {
...rest,
- ...(normalizedFiles && { files: normalizedFiles }),
+ ...(normalizedFiles ? { files: normalizedFiles } : {}),
+ ...(parsedHistoryLength !== undefined ? { historyLength: parsedHistoryLength } : {}),
}
},
},
},
+
inputs: {
- operation: {
- type: 'string',
- description: 'A2A operation to perform',
- },
- agentUrl: {
- type: 'string',
- description: 'A2A endpoint URL',
- },
- message: {
- type: 'string',
- description: 'Message to send to the agent',
- },
- taskId: {
- type: 'string',
- description: 'Task ID to query, cancel, continue, or configure',
- },
- contextId: {
- type: 'string',
- description: 'Context ID for conversation continuity',
- },
- data: {
- type: 'json',
- description: 'Structured data to include with the message',
- },
- files: {
- type: 'array',
- description: 'Files to include with the message (canonical param)',
- },
- historyLength: {
- type: 'number',
- description: 'Number of history messages to include',
- },
- webhookUrl: {
- type: 'string',
- description: 'HTTPS webhook URL for push notifications',
- },
- token: {
- type: 'string',
- description: 'Token for webhook validation',
- },
- apiKey: {
- type: 'string',
- description: 'API key for authentication',
- },
+ operation: { type: 'string', description: 'A2A operation to perform' },
+ agentUrl: { type: 'string', description: 'A2A endpoint URL' },
+ message: { type: 'string', description: 'Message to send to the agent' },
+ data: { type: 'json', description: 'Structured data to include with the message' },
+ files: { type: 'array', description: 'Files to include with the message (canonical param)' },
+ contextId: { type: 'string', description: 'Context ID for conversation continuity' },
+ taskId: { type: 'string', description: 'Task ID to continue, query, or cancel' },
+ historyLength: { type: 'number', description: 'Number of history messages to include' },
+ apiKey: { type: 'string', description: 'API key for authentication' },
},
+
outputs: {
- content: {
- type: 'string',
- description: 'The text response from the agent',
- },
- taskId: {
- type: 'string',
- description: 'Task ID for follow-up interactions',
- },
- contextId: {
- type: 'string',
- description: 'Context ID for conversation continuity',
- },
- state: {
- type: 'string',
- description: 'Task state (completed, failed, etc.)',
- },
- artifacts: {
- type: 'array',
- description: 'Structured output artifacts from the agent',
- },
- history: {
- type: 'array',
- description: 'Full message history of the conversation',
- },
- cancelled: {
- type: 'boolean',
- description: 'Whether the task was successfully cancelled',
- },
- isRunning: {
- type: 'boolean',
- description: 'Whether the task is still running',
- },
- name: {
- type: 'string',
- description: 'Agent name',
- },
- description: {
- type: 'string',
- description: 'Agent description',
- },
- url: {
- type: 'string',
- description: 'Agent endpoint URL',
- },
- version: {
- type: 'string',
- description: 'Agent version',
- },
- capabilities: {
- type: 'json',
- description: 'Agent capabilities (streaming, pushNotifications, etc.)',
- },
- skills: {
- type: 'array',
- description: 'Skills the agent can perform',
- },
- authentication: {
- type: 'json',
- description: 'Supported authentication schemes',
- },
- webhookUrl: {
- type: 'string',
- description: 'Configured webhook URL',
- },
- token: {
- type: 'string',
- description: 'Webhook validation token',
- },
- exists: {
- type: 'boolean',
- description: 'Whether push notification config exists',
- },
- success: {
- type: 'boolean',
- description: 'Whether the operation was successful',
- },
+ content: { type: 'string', description: 'Agent response text' },
+ taskId: { type: 'string', description: 'Task identifier' },
+ contextId: { type: 'string', description: 'Conversation/context identifier' },
+ state: { type: 'string', description: 'Task lifecycle state' },
+ artifacts: { type: 'array', description: 'Structured task output artifacts' },
+ canceled: { type: 'boolean', description: 'Whether the task was canceled' },
+ name: { type: 'string', description: 'Agent display name' },
+ description: { type: 'string', description: 'Agent description' },
+ url: { type: 'string', description: 'Agent endpoint URL' },
+ version: { type: 'string', description: "Agent's own version" },
+ protocolVersion: { type: 'string', description: 'A2A protocol version' },
+ capabilities: { type: 'json', description: 'Agent capability flags' },
+ skills: { type: 'array', description: 'Agent skills' },
+ defaultInputModes: { type: 'array', description: 'Default input media types' },
+ defaultOutputModes: { type: 'array', description: 'Default output media types' },
},
}
diff --git a/apps/sim/blocks/blocks/airtable.ts b/apps/sim/blocks/blocks/airtable.ts
index 6f33716c3ca..faedd66e8c5 100644
--- a/apps/sim/blocks/blocks/airtable.ts
+++ b/apps/sim/blocks/blocks/airtable.ts
@@ -11,7 +11,7 @@ export const AirtableBlock: BlockConfig = {
description: 'Read, create, and update Airtable',
authMode: AuthMode.OAuth,
longDescription:
- 'Integrates Airtable into the workflow. Can list bases, list tables (with schema), and create, get, list, or update records. Can also be used in trigger mode to trigger a workflow when an update is made to an Airtable table.',
+ 'Integrates Airtable into the workflow. Can list bases, list tables (with schema), and create, get, list, update, upsert, or delete records. Can also be used in trigger mode to trigger a workflow when an update is made to an Airtable table.',
docsLink: 'https://docs.sim.ai/integrations/airtable',
category: 'tools',
integrationType: IntegrationType.Databases,
@@ -31,6 +31,8 @@ export const AirtableBlock: BlockConfig = {
{ label: 'Create Records', id: 'create' },
{ label: 'Update Record', id: 'update' },
{ label: 'Update Multiple Records', id: 'updateMultiple' },
+ { label: 'Upsert Records', id: 'upsert' },
+ { label: 'Delete Records', id: 'delete' },
],
value: () => 'list',
},
@@ -162,7 +164,7 @@ Return ONLY the formula - no explanations, no quotes around the entire formula.`
title: 'Records (JSON Array)',
type: 'code',
placeholder: 'For Create: `[{ "fields": { ... } }]`\n',
- condition: { field: 'operation', value: ['create', 'updateMultiple'] },
+ condition: { field: 'operation', value: ['create', 'updateMultiple', 'upsert'] },
required: true,
wandConfig: {
enabled: true,
@@ -237,6 +239,58 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
generationType: 'json-object',
},
},
+ {
+ id: 'fieldsToMergeOn',
+ title: 'Fields to Merge On (JSON Array)',
+ type: 'code',
+ placeholder: 'Field names to match existing records on, e.g., `["Name"]`',
+ condition: { field: 'operation', value: 'upsert' },
+ required: true,
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an Airtable fieldsToMergeOn JSON array based on the user's description.
+This is a list of field names (max 3) used to match existing records during an upsert.
+A record is updated when all of these fields match an existing record, otherwise it is created.
+
+Format:
+["Field Name", "Another Field"]
+
+Examples:
+- "match on email" -> ["Email"]
+- "match on name and company" -> ["Name", "Company"]
+
+Return ONLY the valid JSON array of field name strings - no explanations, no markdown.`,
+ placeholder: 'Describe which fields uniquely identify a record...',
+ generationType: 'json-object',
+ },
+ },
+ {
+ id: 'typecast',
+ title: 'Typecast',
+ type: 'switch',
+ condition: { field: 'operation', value: 'upsert' },
+ mode: 'advanced',
+ },
+ {
+ id: 'recordIds',
+ title: 'Record IDs (JSON Array)',
+ type: 'code',
+ placeholder: 'IDs of records to delete, e.g., `["recXXXXXXXXXXXXXX"]`',
+ condition: { field: 'operation', value: 'delete' },
+ required: true,
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an Airtable record IDs JSON array based on the user's description.
+Each record ID starts with "rec".
+
+Format:
+["recXXXXXXXXXXXXXX", "recYYYYYYYYYYYYYY"]
+
+Return ONLY the valid JSON array of record ID strings - no explanations, no markdown.`,
+ placeholder: 'Describe which records to delete...',
+ generationType: 'json-object',
+ },
+ },
...getTrigger('airtable_webhook').subBlocks,
],
tools: {
@@ -248,6 +302,8 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
'airtable_create_records',
'airtable_update_record',
'airtable_update_multiple_records',
+ 'airtable_upsert_records',
+ 'airtable_delete_records',
'airtable_get_base_schema',
],
config: {
@@ -267,6 +323,10 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
return 'airtable_update_record'
case 'updateMultiple':
return 'airtable_update_multiple_records'
+ case 'upsert':
+ return 'airtable_upsert_records'
+ case 'delete':
+ return 'airtable_delete_records'
case 'getSchema':
return 'airtable_get_base_schema'
default:
@@ -274,18 +334,32 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
}
},
params: (params) => {
- const { oauthCredential, records, fields, ...rest } = params
+ const { oauthCredential, records, fields, fieldsToMergeOn, recordIds, typecast, ...rest } =
+ params
let parsedRecords: any | undefined
let parsedFields: any | undefined
+ let parsedFieldsToMergeOn: any | undefined
+ let parsedRecordIds: any | undefined
// Parse JSON inputs safely
try {
- if (records && (params.operation === 'create' || params.operation === 'updateMultiple')) {
+ if (
+ records &&
+ (params.operation === 'create' ||
+ params.operation === 'updateMultiple' ||
+ params.operation === 'upsert')
+ ) {
parsedRecords = JSON.parse(records)
}
if (fields && params.operation === 'update') {
parsedFields = JSON.parse(fields)
}
+ if (fieldsToMergeOn && params.operation === 'upsert') {
+ parsedFieldsToMergeOn = JSON.parse(fieldsToMergeOn)
+ }
+ if (recordIds && params.operation === 'delete') {
+ parsedRecordIds = JSON.parse(recordIds)
+ }
} catch (error: any) {
throw new Error(`Invalid JSON input for ${params.operation} operation: ${error.message}`)
}
@@ -300,6 +374,15 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
case 'create':
case 'updateMultiple':
return { ...baseParams, records: parsedRecords }
+ case 'upsert':
+ return {
+ ...baseParams,
+ records: parsedRecords,
+ fieldsToMergeOn: parsedFieldsToMergeOn,
+ ...(typecast != null ? { typecast: typecast === true || typecast === 'true' } : {}),
+ }
+ case 'delete':
+ return { ...baseParams, recordIds: parsedRecordIds }
case 'update':
return { ...baseParams, fields: parsedFields }
default:
@@ -317,8 +400,11 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
recordId: { type: 'string', description: 'Record identifier' }, // Required for get/update
maxRecords: { type: 'number', description: 'Maximum records to return' }, // Optional for list
filterFormula: { type: 'string', description: 'Filter formula expression' }, // Optional for list
- records: { type: 'json', description: 'Record data array' }, // Required for create/updateMultiple
+ records: { type: 'json', description: 'Record data array' }, // Required for create/updateMultiple/upsert
fields: { type: 'json', description: 'Field data object' }, // Required for update single
+ fieldsToMergeOn: { type: 'json', description: 'Field names to match records on' }, // Required for upsert
+ typecast: { type: 'boolean', description: 'Auto-convert string values to field types' }, // Optional for upsert
+ recordIds: { type: 'json', description: 'Record IDs to delete' }, // Required for delete
},
// Output structure depends on the operation, covered by AirtableResponse union type
outputs: {
@@ -326,6 +412,8 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
tables: { type: 'json', description: 'Table schemas with fields and views' },
records: { type: 'json', description: 'Retrieved record data' },
record: { type: 'json', description: 'Single record data' },
+ createdRecords: { type: 'json', description: 'IDs of records created during upsert' },
+ updatedRecords: { type: 'json', description: 'IDs of records updated during upsert' },
metadata: { type: 'json', description: 'Operation metadata' },
// Trigger outputs
event_type: { type: 'string', description: 'Type of Airtable event' },
diff --git a/apps/sim/blocks/blocks/apollo.ts b/apps/sim/blocks/blocks/apollo.ts
index 9b792a4151b..8e5bbb5674e 100644
--- a/apps/sim/blocks/blocks/apollo.ts
+++ b/apps/sim/blocks/blocks/apollo.ts
@@ -1,5 +1,5 @@
+import { Users } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
-import { Users } from '@/components/emcn/icons'
import { ApolloIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
diff --git a/apps/sim/blocks/blocks/circleback.ts b/apps/sim/blocks/blocks/circleback.ts
index 56ba5ecb9b4..1d80b7ee97c 100644
--- a/apps/sim/blocks/blocks/circleback.ts
+++ b/apps/sim/blocks/blocks/circleback.ts
@@ -1,4 +1,4 @@
-import { ClipboardList, Table } from '@/components/emcn/icons'
+import { ClipboardList, Table } from '@sim/emcn/icons'
import { CirclebackIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
diff --git a/apps/sim/blocks/blocks/clickhouse.ts b/apps/sim/blocks/blocks/clickhouse.ts
index 4ae45311d86..9372883faad 100644
--- a/apps/sim/blocks/blocks/clickhouse.ts
+++ b/apps/sim/blocks/blocks/clickhouse.ts
@@ -1,14 +1,5 @@
+import { Bell, ClipboardList, Database, File, Search, Server, Trash, Wrench } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
-import {
- Bell,
- ClipboardList,
- Database,
- File,
- Search,
- Server,
- Trash,
- Wrench,
-} from '@/components/emcn/icons'
import { ClickHouseIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts
index f0594888a1d..93b0dcda9ff 100644
--- a/apps/sim/blocks/blocks/confluence.ts
+++ b/apps/sim/blocks/blocks/confluence.ts
@@ -1,4 +1,4 @@
-import { Search } from '@/components/emcn/icons'
+import { Search } from '@sim/emcn/icons'
import { ConfluenceIcon, PagerDutyIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
diff --git a/apps/sim/blocks/blocks/elevenlabs.ts b/apps/sim/blocks/blocks/elevenlabs.ts
index 20b564007a1..b981d3f94ff 100644
--- a/apps/sim/blocks/blocks/elevenlabs.ts
+++ b/apps/sim/blocks/blocks/elevenlabs.ts
@@ -1,13 +1,36 @@
import { ElevenLabsIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types'
+import { normalizeFileInput } from '@/blocks/utils'
import type { ElevenLabsBlockResponse } from '@/tools/elevenlabs/types'
+const VOICE_OPERATIONS = [
+ 'tts',
+ 'speech_to_speech',
+ 'get_voice',
+ 'get_voice_settings',
+ 'edit_voice_settings',
+]
+const AUDIO_INPUT_OPERATIONS = ['speech_to_speech', 'audio_isolation']
+
+const toNumber = (value: unknown): number | undefined => {
+ if (value === undefined || value === null || value === '') return undefined
+ const parsed = Number(value)
+ return Number.isFinite(parsed) ? parsed : undefined
+}
+
+const toBoolean = (value: unknown): boolean | undefined => {
+ if (value === undefined || value === null || value === '') return undefined
+ if (typeof value === 'boolean') return value
+ return String(value).toLowerCase() === 'true'
+}
+
export const ElevenLabsBlock: BlockConfig = {
type: 'elevenlabs',
name: 'ElevenLabs',
- description: 'Convert text to speech with ElevenLabs',
+ description: 'Generate and transform audio with ElevenLabs',
authMode: AuthMode.ApiKey,
- longDescription: 'Integrate ElevenLabs into the workflow. Can convert text to speech.',
+ longDescription:
+ 'Integrate ElevenLabs into the workflow. Convert text to speech, generate sound effects, transform voices, isolate audio, and manage voices, models, and account settings.',
docsLink: 'https://docs.sim.ai/integrations/elevenlabs',
category: 'tools',
integrationType: IntegrationType.AI,
@@ -15,20 +38,63 @@ export const ElevenLabsBlock: BlockConfig = {
icon: ElevenLabsIcon,
subBlocks: [
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [
+ { label: 'Text to Speech', id: 'tts' },
+ { label: 'Sound Effects', id: 'sound_effects' },
+ { label: 'Speech to Speech', id: 'speech_to_speech' },
+ { label: 'Audio Isolation', id: 'audio_isolation' },
+ { label: 'List Voices', id: 'list_voices' },
+ { label: 'Get Voice', id: 'get_voice' },
+ { label: 'Get Voice Settings', id: 'get_voice_settings' },
+ { label: 'Edit Voice Settings', id: 'edit_voice_settings' },
+ { label: 'List Models', id: 'list_models' },
+ { label: 'Get User Info', id: 'get_user' },
+ ],
+ value: () => 'tts',
+ required: true,
+ },
+
{
id: 'text',
title: 'Text',
type: 'long-input',
placeholder: 'Enter the text to convert to speech',
- required: true,
+ condition: { field: 'operation', value: 'tts' },
+ required: { field: 'operation', value: 'tts' },
},
+ {
+ id: 'text',
+ title: 'Sound Prompt',
+ type: 'long-input',
+ placeholder: 'Describe the sound effect (e.g., "thunder rumbling in the distance")',
+ condition: { field: 'operation', value: 'sound_effects' },
+ required: { field: 'operation', value: 'sound_effects' },
+ },
+
{
id: 'voiceId',
title: 'Voice ID',
type: 'short-input',
placeholder: 'Enter the voice ID',
- required: true,
+ condition: { field: 'operation', value: VOICE_OPERATIONS },
+ required: { field: 'operation', value: VOICE_OPERATIONS },
+ },
+
+ {
+ id: 'audioFile',
+ title: 'Audio File',
+ type: 'file-upload',
+ placeholder: 'Upload an audio file',
+ multiple: false,
+ acceptedTypes: '.mp3,.m4a,.wav,.webm,.ogg,.flac,.aac,.opus',
+ condition: { field: 'operation', value: AUDIO_INPUT_OPERATIONS },
+ required: { field: 'operation', value: AUDIO_INPUT_OPERATIONS },
},
+
{
id: 'modelId',
title: 'Model ID',
@@ -42,13 +108,35 @@ export const ElevenLabsBlock: BlockConfig = {
{ label: 'eleven_v3', id: 'eleven_v3' },
],
value: () => 'eleven_monolingual_v1',
+ condition: { field: 'operation', value: 'tts' },
},
+ {
+ id: 'modelId',
+ title: 'Model ID',
+ type: 'dropdown',
+ options: [{ label: 'eleven_text_to_sound_v2', id: 'eleven_text_to_sound_v2' }],
+ value: () => 'eleven_text_to_sound_v2',
+ condition: { field: 'operation', value: 'sound_effects' },
+ },
+ {
+ id: 'modelId',
+ title: 'Model ID',
+ type: 'dropdown',
+ options: [
+ { label: 'eleven_english_sts_v2', id: 'eleven_english_sts_v2' },
+ { label: 'eleven_multilingual_sts_v2', id: 'eleven_multilingual_sts_v2' },
+ ],
+ value: () => 'eleven_english_sts_v2',
+ condition: { field: 'operation', value: 'speech_to_speech' },
+ },
+
{
id: 'stability',
title: 'Stability',
type: 'short-input',
placeholder: '0.0 to 1.0 (e.g., 0.5)',
mode: 'advanced',
+ condition: { field: 'operation', value: 'tts' },
},
{
id: 'similarityBoost',
@@ -56,7 +144,118 @@ export const ElevenLabsBlock: BlockConfig = {
type: 'short-input',
placeholder: '0.0 to 1.0 (e.g., 0.75)',
mode: 'advanced',
+ condition: { field: 'operation', value: 'tts' },
+ },
+
+ {
+ id: 'durationSeconds',
+ title: 'Duration (seconds)',
+ type: 'short-input',
+ placeholder: '0.5 to 30 (leave empty to auto-determine)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'sound_effects' },
+ },
+ {
+ id: 'promptInfluence',
+ title: 'Prompt Influence',
+ type: 'short-input',
+ placeholder: '0.0 to 1.0 (e.g., 0.3)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'sound_effects' },
+ },
+ {
+ id: 'loop',
+ title: 'Loop',
+ type: 'switch',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'sound_effects' },
+ },
+
+ {
+ id: 'removeBackgroundNoise',
+ title: 'Remove Background Noise',
+ type: 'switch',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'speech_to_speech' },
+ },
+
+ {
+ id: 'editStability',
+ title: 'Stability',
+ type: 'short-input',
+ placeholder: '0.0 to 1.0 (e.g., 0.5)',
+ condition: { field: 'operation', value: 'edit_voice_settings' },
+ },
+ {
+ id: 'editSimilarityBoost',
+ title: 'Similarity Boost',
+ type: 'short-input',
+ placeholder: '0.0 to 1.0 (e.g., 0.75)',
+ condition: { field: 'operation', value: 'edit_voice_settings' },
+ },
+ {
+ id: 'editStyle',
+ title: 'Style',
+ type: 'short-input',
+ placeholder: '0.0 to 1.0 (e.g., 0.0)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'edit_voice_settings' },
+ },
+ {
+ id: 'editSpeed',
+ title: 'Speed',
+ type: 'short-input',
+ placeholder: '1.0 = normal',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'edit_voice_settings' },
+ },
+ {
+ id: 'editUseSpeakerBoost',
+ title: 'Use Speaker Boost',
+ type: 'switch',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'edit_voice_settings' },
},
+
+ {
+ id: 'search',
+ title: 'Search',
+ type: 'short-input',
+ placeholder: 'Filter voices by name, description, labels, or category',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+ {
+ id: 'category',
+ title: 'Category',
+ type: 'dropdown',
+ options: [
+ { label: 'Any', id: '' },
+ { label: 'Premade', id: 'premade' },
+ { label: 'Cloned', id: 'cloned' },
+ { label: 'Generated', id: 'generated' },
+ { label: 'Professional', id: 'professional' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+ {
+ id: 'pageSize',
+ title: 'Page Size',
+ type: 'short-input',
+ placeholder: '1 to 100 (default 10)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+ {
+ id: 'nextPageToken',
+ title: 'Next Page Token',
+ type: 'short-input',
+ placeholder: 'Token from a previous response to fetch the next page',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+
{
id: 'apiKey',
title: 'API Key',
@@ -68,39 +267,212 @@ export const ElevenLabsBlock: BlockConfig = {
],
tools: {
- access: ['elevenlabs_tts'],
+ access: [
+ 'elevenlabs_tts',
+ 'elevenlabs_sound_effects',
+ 'elevenlabs_speech_to_speech',
+ 'elevenlabs_audio_isolation',
+ 'elevenlabs_list_voices',
+ 'elevenlabs_get_voice',
+ 'elevenlabs_get_voice_settings',
+ 'elevenlabs_edit_voice_settings',
+ 'elevenlabs_list_models',
+ 'elevenlabs_get_user',
+ ],
config: {
- tool: () => 'elevenlabs_tts',
+ tool: (params) => `elevenlabs_${params.operation || 'tts'}`,
params: (params) => {
- const parseUnitInterval = (value: unknown): number | undefined => {
- if (value === undefined || value === null || value === '') return undefined
- const n = Number(value)
- return Number.isFinite(n) ? n : undefined
- }
+ const audioFile = normalizeFileInput(params.audioFile, { single: true })
return {
apiKey: params.apiKey,
text: params.text,
voiceId: params.voiceId,
modelId: params.modelId,
- stability: parseUnitInterval(params.stability),
- similarityBoost: parseUnitInterval(params.similarityBoost),
+ audioFile,
+ search: params.search,
+ category: params.category || undefined,
+ nextPageToken: params.nextPageToken || undefined,
+ pageSize: toNumber(params.pageSize),
+ stability: toNumber(
+ params.operation === 'edit_voice_settings' ? params.editStability : params.stability
+ ),
+ similarityBoost: toNumber(
+ params.operation === 'edit_voice_settings'
+ ? params.editSimilarityBoost
+ : params.similarityBoost
+ ),
+ style: toNumber(params.editStyle),
+ speed: toNumber(params.editSpeed),
+ useSpeakerBoost: toBoolean(params.editUseSpeakerBoost),
+ durationSeconds: toNumber(params.durationSeconds),
+ promptInfluence: toNumber(params.promptInfluence),
+ loop: toBoolean(params.loop),
+ removeBackgroundNoise: toBoolean(params.removeBackgroundNoise),
}
},
},
},
inputs: {
- text: { type: 'string', description: 'Text to convert' },
+ operation: { type: 'string', description: 'Operation to perform' },
+ text: { type: 'string', description: 'Text to convert or sound prompt' },
voiceId: { type: 'string', description: 'Voice identifier' },
+ audioFile: { type: 'json', description: 'Source audio file (UserFile)' },
modelId: { type: 'string', description: 'Model identifier' },
stability: { type: 'number', description: 'Voice stability 0.0-1.0' },
similarityBoost: { type: 'number', description: 'Similarity boost 0.0-1.0' },
+ durationSeconds: { type: 'number', description: 'Sound effect length in seconds (0.5-30)' },
+ promptInfluence: { type: 'number', description: 'Sound prompt influence 0.0-1.0' },
+ loop: { type: 'boolean', description: 'Generate a seamlessly looping sound effect' },
+ removeBackgroundNoise: { type: 'boolean', description: 'Isolate the voice during conversion' },
+ editStability: { type: 'number', description: 'Voice stability to set 0.0-1.0' },
+ editSimilarityBoost: { type: 'number', description: 'Similarity boost to set 0.0-1.0' },
+ editStyle: { type: 'number', description: 'Style exaggeration to set 0.0-1.0' },
+ editSpeed: { type: 'number', description: 'Speech speed to set (1.0 = normal)' },
+ editUseSpeakerBoost: { type: 'boolean', description: 'Enable speaker boost' },
+ search: { type: 'string', description: 'Voice search filter' },
+ category: { type: 'string', description: 'Voice category filter' },
+ pageSize: { type: 'number', description: 'Number of voices to return (1-100)' },
apiKey: { type: 'string', description: 'ElevenLabs API key' },
},
outputs: {
- audioUrl: { type: 'string', description: 'Generated audio URL' },
- audioFile: { type: 'file', description: 'Generated audio file' },
+ audioUrl: {
+ type: 'string',
+ description: 'Generated audio URL',
+ condition: {
+ field: 'operation',
+ value: ['tts', 'sound_effects', 'speech_to_speech', 'audio_isolation'],
+ },
+ },
+ audioFile: {
+ type: 'file',
+ description: 'Generated audio file',
+ condition: {
+ field: 'operation',
+ value: ['tts', 'sound_effects', 'speech_to_speech', 'audio_isolation'],
+ },
+ },
+ voices: {
+ type: 'array',
+ description: 'List of voices',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Total number of matching voices',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+ hasMore: {
+ type: 'boolean',
+ description: 'Whether more voices are available',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+ nextPageToken: {
+ type: 'string',
+ description: 'Token to fetch the next page',
+ condition: { field: 'operation', value: 'list_voices' },
+ },
+ voiceId: {
+ type: 'string',
+ description: 'Voice identifier',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ name: {
+ type: 'string',
+ description: 'Voice name',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ category: {
+ type: 'string',
+ description: 'Voice category',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ description: {
+ type: 'string',
+ description: 'Voice description',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ labels: {
+ type: 'json',
+ description: 'Voice labels',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ previewUrl: {
+ type: 'string',
+ description: 'Preview audio URL',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ settings: {
+ type: 'json',
+ description: 'Voice settings',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ availableForTiers: {
+ type: 'array',
+ description: 'Subscription tiers the voice is available on',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ highQualityBaseModelIds: {
+ type: 'array',
+ description: 'Model IDs supporting high-quality output for this voice',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ isOwner: {
+ type: 'boolean',
+ description: 'Whether the current user owns this voice',
+ condition: { field: 'operation', value: 'get_voice' },
+ },
+ stability: {
+ type: 'number',
+ description: 'Voice stability',
+ condition: { field: 'operation', value: 'get_voice_settings' },
+ },
+ similarityBoost: {
+ type: 'number',
+ description: 'Similarity boost',
+ condition: { field: 'operation', value: 'get_voice_settings' },
+ },
+ style: {
+ type: 'number',
+ description: 'Style exaggeration',
+ condition: { field: 'operation', value: 'get_voice_settings' },
+ },
+ useSpeakerBoost: {
+ type: 'boolean',
+ description: 'Whether speaker boost is enabled',
+ condition: { field: 'operation', value: 'get_voice_settings' },
+ },
+ speed: {
+ type: 'number',
+ description: 'Speech speed',
+ condition: { field: 'operation', value: 'get_voice_settings' },
+ },
+ status: {
+ type: 'string',
+ description: 'Edit outcome ("ok" on success)',
+ condition: { field: 'operation', value: 'edit_voice_settings' },
+ },
+ models: {
+ type: 'array',
+ description: 'List of available models',
+ condition: { field: 'operation', value: 'list_models' },
+ },
+ userId: {
+ type: 'string',
+ description: 'User identifier',
+ condition: { field: 'operation', value: 'get_user' },
+ },
+ isNewUser: {
+ type: 'boolean',
+ description: 'Whether the user is new',
+ condition: { field: 'operation', value: 'get_user' },
+ },
+ subscription: {
+ type: 'json',
+ description: 'Subscription and usage details',
+ condition: { field: 'operation', value: 'get_user' },
+ },
},
}
diff --git a/apps/sim/blocks/blocks/firecrawl.ts b/apps/sim/blocks/blocks/firecrawl.ts
index af9ab4d4c07..9b22b80e7ef 100644
--- a/apps/sim/blocks/blocks/firecrawl.ts
+++ b/apps/sim/blocks/blocks/firecrawl.ts
@@ -23,12 +23,18 @@ export const FirecrawlBlock: BlockConfig