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 dd45bd23ccd..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,7 +1,18 @@
'use client'
import { createElement, useMemo, useState } from 'react'
-import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, SecretReveal } from '@sim/emcn'
+import {
+ ArrowRight,
+ Button,
+ ChevronDown,
+ cn,
+ Expandable,
+ ExpandableContent,
+ SecretInput,
+ SecretReveal,
+ Tooltip,
+ toast,
+} from '@sim/emcn'
import { useParams } from 'next/navigation'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
@@ -10,6 +21,12 @@ 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'
@@ -42,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 {
@@ -149,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'
}
@@ -612,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={
+
+
+
+
+ {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 (