diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx
index 6270761fc4..b2ba0064be 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx
@@ -1359,4 +1359,19 @@ describe('recoverTrailingBareOptions', () => {
const { segments } = parseSpecialTags(`Pick one ${bareOptions}`, false)
expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1)
})
+
+ it('recovers an options payload wrapped in the singular `,
+ false
+ )
+ const last = segments[segments.length - 1]
+ expect(last.type).toBe('options')
+ if (last.type === 'options') {
+ expect(Object.keys(last.data)).toEqual(['1', '2', '3'])
+ expect(last.data['1']?.title).toBe('Demo wait — sleep until an agent finishes')
+ }
+ })
})
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 21596a05ea..ceb79140ce 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
@@ -1459,11 +1459,20 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
* already parsed. Never applied mid-stream: a partial JSON tail must not
* flicker between prose and a card.
*/
+const NEAR_MISS_OPTIONS_WRAPPER = /` fails
+ // the brace gate below). Unwrap it and let the strict shape check decide.
+ const nearMiss = NEAR_MISS_OPTIONS_WRAPPER.exec(text)
+ if (nearMiss) {
+ text = `${text.slice(0, nearMiss.index)}${nearMiss[1]}`
+ }
if (!text.trimEnd().endsWith('}')) return
// The payload nests objects, so the START brace is the first one from which
// the remainder parses — probe brace positions left to right (bounded).
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index da266e91fd..fe635a1bc9 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -35,6 +35,7 @@ export interface ToolCatalogEntry {
| 'browser_type'
| 'browser_wait_for'
| 'call_integration_tool'
+ | 'connect_slack_bot'
| 'cp'
| 'create_empty_file'
| 'create_workflow'
@@ -163,6 +164,7 @@ export interface ToolCatalogEntry {
| 'browser_type'
| 'browser_wait_for'
| 'call_integration_tool'
+ | 'connect_slack_bot'
| 'cp'
| 'create_empty_file'
| 'create_workflow'
@@ -1636,6 +1638,36 @@ export const CallIntegrationTool: ToolCatalogEntry = {
requiresApproval: true,
}
+export const ConnectSlackBot: ToolCatalogEntry = {
+ id: 'connect_slack_bot',
+ name: 'connect_slack_bot',
+ route: 'sim',
+ mode: 'async',
+ parameters: {
+ type: 'object',
+ properties: {
+ botTokenEnvVar: {
+ type: 'string',
+ description:
+ 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.',
+ },
+ description: { type: 'string', description: 'Optional description shown on the credential.' },
+ displayName: {
+ type: 'string',
+ description:
+ 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.',
+ },
+ signingSecretEnvVar: {
+ type: 'string',
+ description:
+ "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.",
+ },
+ },
+ required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'],
+ },
+ requiredPermission: 'write',
+}
+
export const Cp: ToolCatalogEntry = {
id: 'cp',
name: 'cp',
@@ -7103,6 +7135,7 @@ export const TOOL_CATALOG: Record = {
[BrowserType.id]: BrowserType,
[BrowserWaitFor.id]: BrowserWaitFor,
[CallIntegrationTool.id]: CallIntegrationTool,
+ [ConnectSlackBot.id]: ConnectSlackBot,
[Cp.id]: Cp,
[CreateEmptyFile.id]: CreateEmptyFile,
[CreateWorkflow.id]: CreateWorkflow,
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index 105022d971..d94d43860e 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -1580,6 +1580,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
+ connect_slack_bot: {
+ parameters: {
+ type: 'object',
+ properties: {
+ botTokenEnvVar: {
+ type: 'string',
+ description:
+ 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.',
+ },
+ description: {
+ type: 'string',
+ description: 'Optional description shown on the credential.',
+ },
+ displayName: {
+ type: 'string',
+ description:
+ 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.',
+ },
+ signingSecretEnvVar: {
+ type: 'string',
+ description:
+ "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.",
+ },
+ },
+ required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'],
+ },
+ resultSchema: undefined,
+ },
cp: {
parameters: {
type: 'object',
diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts
index 61276eb752..b78eac3859 100644
--- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts
+++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import {
+ ConnectSlackBot,
Cp as CpTool,
CreateWorkflow,
CreateWorkspaceMcpServer,
@@ -74,6 +75,7 @@ import {
} from '../tools/handlers/deployment/manage'
import { executeFunctionExecute } from '../tools/handlers/function-execute'
import { executeListIntegrationTools } from '../tools/handlers/integration-tools'
+import { executeConnectSlackBot } from '../tools/handlers/management/connect-slack-bot'
import { executeManageCredential } from '../tools/handlers/management/manage-credential'
import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool'
import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool'
@@ -185,6 +187,7 @@ function buildHandlerMap(): Record {
[ManageSandbox.id]: h(executeManageSandbox),
[ManageSkill.id]: h(executeManageSkill),
[ManageCredential.id]: h(executeManageCredential),
+ [ConnectSlackBot.id]: h(executeConnectSlackBot),
[OauthGetAuthLink.id]: h(executeOAuthGetAuthLink),
// Rolling-deploy compatibility for calls/checkpoints created before OAuth
// moved into terminal credential cards. New agents no longer receive this
diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts
index 788b42781e..81fa287064 100644
--- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts
+++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts
@@ -40,7 +40,7 @@ describe('resolveToolDisplay', () => {
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
path: 'workflows/My Workflow/meta.json',
})?.text
- ).toBe('Read My Workflow')
+ ).toBe('Read metadata for My Workflow')
expect(
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
@@ -49,6 +49,34 @@ describe('resolveToolDisplay', () => {
).toBe('Read RET XYZ')
})
+ it('labels resource artifact reads distinctly instead of repeating the resource name', () => {
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
+ path: 'workflows/Elder v2/The Elder/state.json',
+ })?.text
+ ).toBe('Read The Elder')
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
+ path: 'workflows/Elder v2/The Elder/deployment.json',
+ })?.text
+ ).toBe('Read deployment status for The Elder')
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.error, {
+ path: 'workflows/Elder v2/The Elder/lint.json',
+ })?.text
+ ).toBe('Attempted to read lint results for The Elder')
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
+ path: 'tables/CRM/Leads/views.json',
+ })?.text
+ ).toBe('Read views of Leads')
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
+ path: 'knowledgebases/Contracts/documents.json',
+ })?.text
+ ).toBe('Read documents in Contracts')
+ })
+
it('decodes percent-encoded VFS path segments for display', () => {
expect(
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
@@ -60,7 +88,7 @@ describe('resolveToolDisplay', () => {
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
path: 'workflows/My%20Workflow/meta.json',
})?.text
- ).toBe('Read My Workflow')
+ ).toBe('Read metadata for My Workflow')
expect(
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts
index 343c9e2712..2efa7d43fe 100644
--- a/apps/sim/lib/copilot/tools/client/store-utils.ts
+++ b/apps/sim/lib/copilot/tools/client/store-utils.ts
@@ -107,11 +107,39 @@ function describeReadTarget(path: string | undefined): string | undefined {
}
if (resourceType === 'workflow') {
- return stripExtension(getLeafResourceSegment(segments))
+ return describeResourceArtifactTarget(segments)
}
- const resourceName = segments[1] || segments[segments.length - 1]
- return stripExtension(resourceName)
+ return describeResourceArtifactTarget(segments)
+}
+
+/**
+ * Resource-scoped artifact files, labeled the same prefix way as
+ * FILE_FACET_LABELS. `state.json` is the empty facet — reading a workflow means
+ * reading its state — so "Read The Elder", "Read metadata for The Elder", and
+ * "Read deployment status for The Elder" render as three distinct rows instead
+ * of three identical "Read The Elder" lines.
+ */
+const RESOURCE_ARTIFACT_LABELS: Record = {
+ 'state.json': '',
+ 'meta.json': 'metadata for',
+ 'lint.json': 'lint results for',
+ 'deployment.json': 'deployment status for',
+ 'versions.json': 'versions of',
+ 'executions.json': 'runs of',
+ 'views.json': 'views of',
+ 'documents.json': 'documents in',
+ 'connectors.json': 'connectors of',
+}
+
+function describeResourceArtifactTarget(segments: string[]): string {
+ const lastSegment = segments[segments.length - 1] || ''
+ const resourceName = stripExtension(getLeafResourceSegment(segments))
+ const artifactLabel = RESOURCE_ARTIFACT_LABELS[lastSegment]
+ if (artifactLabel !== undefined && segments.length > 1) {
+ return artifactLabel ? `${artifactLabel} ${resourceName}` : resourceName
+ }
+ return resourceName
}
// A workspace file is addressed as a directory of facets in the VFS
diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts
new file mode 100644
index 0000000000..46dc0b0dc0
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ performCreateCredential: vi.fn(),
+ getEffectiveDecryptedEnv: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/orchestration', () => ({
+ performCreateCredential: mocks.performCreateCredential,
+}))
+vi.mock('@/lib/environment/utils', () => ({
+ getEffectiveDecryptedEnv: mocks.getEffectiveDecryptedEnv,
+}))
+
+import { executeConnectSlackBot } from './connect-slack-bot'
+
+const context = { userId: 'user-1', workspaceId: 'ws-1' } as never
+
+const validParams = {
+ displayName: 'Elder Bot',
+ signingSecretEnvVar: 'SLACK_SIGNING_SECRET',
+ botTokenEnvVar: 'SLACK_BOT_TOKEN',
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getEffectiveDecryptedEnv.mockResolvedValue({
+ SLACK_SIGNING_SECRET: 'shhh',
+ SLACK_BOT_TOKEN: 'xoxb-123',
+ })
+ mocks.performCreateCredential.mockResolvedValue({
+ success: true,
+ created: true,
+ credential: { id: 'cred-1', displayName: 'Elder Bot' },
+ })
+})
+
+describe('executeConnectSlackBot', () => {
+ it('resolves env vars server-side and mints the credential with the request URL', async () => {
+ const result = await executeConnectSlackBot(validParams, context)
+
+ expect(mocks.performCreateCredential).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceId: 'ws-1',
+ userId: 'user-1',
+ type: 'service_account',
+ providerId: 'slack-custom-bot',
+ displayName: 'Elder Bot',
+ signingSecret: 'shhh',
+ botToken: 'xoxb-123',
+ })
+ )
+ expect(result.success).toBe(true)
+ expect(result.output).toMatchObject({
+ credentialId: 'cred-1',
+ created: true,
+ requestUrl: expect.stringContaining('/api/webhooks/slack/custom/cred-1'),
+ })
+ })
+
+ it('names the missing env vars without leaking any values', async () => {
+ mocks.getEffectiveDecryptedEnv.mockResolvedValue({ SLACK_SIGNING_SECRET: 'shhh' })
+
+ const result = await executeConnectSlackBot(validParams, context)
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('SLACK_BOT_TOKEN')
+ expect(result.error).not.toContain('shhh')
+ expect(mocks.performCreateCredential).not.toHaveBeenCalled()
+ })
+
+ it('requires displayName and both env var names', async () => {
+ const missingName = await executeConnectSlackBot(
+ { signingSecretEnvVar: 'A', botTokenEnvVar: 'B' },
+ context
+ )
+ expect(missingName.success).toBe(false)
+ expect(missingName.error).toContain('displayName')
+
+ const missingVars = await executeConnectSlackBot({ displayName: 'Bot' }, context)
+ expect(missingVars.success).toBe(false)
+ expect(missingVars.error).toContain('signingSecretEnvVar')
+ })
+
+ it('surfaces orchestration failures (e.g. auth.test rejection or name conflict)', async () => {
+ mocks.performCreateCredential.mockResolvedValue({
+ success: false,
+ error: 'Slack rejected the bot token',
+ })
+
+ const result = await executeConnectSlackBot(validParams, context)
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('Slack rejected the bot token')
+ })
+
+ it('requires workspace scope', async () => {
+ const result = await executeConnectSlackBot(validParams, { userId: 'user-1' } as never)
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('Workspace')
+ })
+})
diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts
new file mode 100644
index 0000000000..f02599e8d8
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts
@@ -0,0 +1,91 @@
+import { toError } from '@sim/utils/errors'
+import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
+import { performCreateCredential } from '@/lib/credentials/orchestration'
+import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
+import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
+import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url'
+
+/**
+ * Mints a reusable Slack custom-bot credential from secrets ALREADY stored as
+ * environment variables (a v1 setup being migrated, or values saved via
+ * set_environment_variables after a browser-agent extraction). The agent
+ * passes env-var NAMES; the values are resolved here and validated by the
+ * credential orchestration (Slack auth.test), so no secret ever appears in
+ * tool args, checkpoints, or transcripts. When the USER holds the secrets,
+ * the service_account credential card is the right path instead.
+ */
+export function executeConnectSlackBot(
+ rawParams: Record,
+ context: ExecutionContext
+): Promise {
+ const params = rawParams as {
+ displayName?: string
+ description?: string
+ signingSecretEnvVar?: string
+ botTokenEnvVar?: string
+ }
+ return (async () => {
+ try {
+ if (!context?.userId) {
+ return { success: false, error: 'Authentication required' }
+ }
+ const workspaceId = context.workspaceId
+ if (!workspaceId) {
+ return { success: false, error: 'Workspace scope required' }
+ }
+ const { displayName, description, signingSecretEnvVar, botTokenEnvVar } = params
+ if (!displayName) {
+ return { success: false, error: 'displayName is required' }
+ }
+ if (!signingSecretEnvVar || !botTokenEnvVar) {
+ return {
+ success: false,
+ error:
+ 'signingSecretEnvVar and botTokenEnvVar are required: the NAMES of the environment variables holding the Slack signing secret and bot token. Save the values with set_environment_variables first if needed.',
+ }
+ }
+
+ const env = await getEffectiveDecryptedEnv(context.userId, workspaceId)
+ const missing = [signingSecretEnvVar, botTokenEnvVar].filter((name) => !env[name])
+ if (missing.length > 0) {
+ return {
+ success: false,
+ error: `Environment variable(s) not found: ${missing.join(', ')}. Check environment/ in the VFS, or save the values with set_environment_variables first.`,
+ }
+ }
+
+ const result = await performCreateCredential({
+ workspaceId,
+ userId: context.userId,
+ type: 'service_account',
+ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
+ displayName,
+ description,
+ signingSecret: env[signingSecretEnvVar],
+ botToken: env[botTokenEnvVar],
+ })
+ if (!result.success || !result.credential) {
+ return {
+ success: false,
+ error:
+ result.error ||
+ 'Failed to connect the Slack custom bot. If a credential with this display name already exists, reuse it (environment/credentials.json) or pick a different name.',
+ }
+ }
+ return {
+ success: true,
+ output: {
+ credentialId: result.credential.id,
+ displayName: result.credential.displayName,
+ created: result.created !== false,
+ // The Slack app's Event Subscriptions Request URL — one per
+ // credential, shared by every trigger that selects it; live
+ // immediately, no deployment needed.
+ requestUrl: buildSlackCustomBotRequestUrl(result.credential.id),
+ },
+ }
+ } catch (error) {
+ return { success: false, error: toError(error).message }
+ }
+ })()
+}
diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts
index 8f1fcc8343..4059797c33 100644
--- a/apps/sim/lib/copilot/tools/tool-display.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.ts
@@ -492,6 +492,7 @@ const TOOL_TITLES: Record = {
list_workspace_mcp_servers: 'Listing MCP servers',
load_deployment: 'Loading deployment',
save_upload: 'Saving upload',
+ connect_slack_bot: 'Connecting Slack bot',
manage_sandbox: 'Managing sandbox',
manage_scheduled_task: 'Managing scheduled task',
move_file: 'Moving file',
@@ -1034,6 +1035,7 @@ const COMPLETED_VERB_REWRITES: Record = {
Crawling: 'Crawled',
Creating: 'Created',
Deleting: 'Deleted',
+ Connecting: 'Connected',
Deploying: 'Deployed',
Dragging: 'Dragged',
Inserting: 'Inserted',