diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx
new file mode 100644
index 00000000000..4cdb2d4a9ec
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx
@@ -0,0 +1,59 @@
+'use client'
+
+import {
+ MemberRow,
+ SKILL_EDITOR_ROLE_OPTIONS,
+ type SkillEditorRole,
+ skillEditorLockReason,
+} from '@/components/permissions'
+import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail'
+import type { SkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members'
+
+interface SkillEditorsCardProps {
+ editors: SkillEditorsController
+ /** Whether the viewer can edit the skill (and therefore manage its editors). */
+ canEdit: boolean
+}
+
+/**
+ * Page-styled editor roster for the skill detail page: workspace admins
+ * (derived, always editors) and explicitly added editors. Everyone in the
+ * workspace can already see and use the skill — this list gates editing only.
+ * Adding people happens through the header Share action.
+ *
+ * Rows are the shared {@link MemberRow} so the roster is chrome-identical to the
+ * credential detail surface. Skill membership is binary, so the role control
+ * carries a single `Editor` option and is disabled on every row; a derived
+ * (workspace-admin) row also locks Remove and explains the inheritance on hover.
+ */
+export function SkillEditorsCard({ editors, canEdit }: SkillEditorsCardProps) {
+ return (
+
+ {editors.editorsError ? (
+
+ Couldn't load editors. You may no longer have access to this skill.
+
+ ) : editors.editorsLoading ? null : (
+
+ {editors.editors.map((editor) => (
+
+ key={editor.id}
+ member={{
+ userId: editor.userId,
+ userName: editor.userName,
+ userEmail: editor.userEmail,
+ role: 'editor',
+ }}
+ roleOptions={SKILL_EDITOR_ROLE_OPTIONS}
+ lockReason={skillEditorLockReason(editor.isWorkspaceAdmin)}
+ canManage={canEdit}
+ roleDisabled
+ removeDisabled={editor.isWorkspaceAdmin}
+ onRemove={() => editors.removeEditor(editor.userId)}
+ />
+ ))}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx
new file mode 100644
index 00000000000..5f22dccfcf4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+import { SkillDetail } from '@/app/workspace/[workspaceId]/skills/[skillId]/skill-detail'
+
+export const metadata: Metadata = {
+ title: 'Skill',
+}
+
+export default async function SkillDetailPage({
+ params,
+}: {
+ params: Promise<{ workspaceId: string; skillId: string }>
+}) {
+ const { workspaceId, skillId } = await params
+ return
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
new file mode 100644
index 00000000000..9029bbda79e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx
@@ -0,0 +1,356 @@
+'use client'
+
+import { type ReactNode, useState } from 'react'
+import {
+ Chip,
+ ChipConfirmModal,
+ ChipInput,
+ ChipLink,
+ ChipTextarea,
+ chipFieldSurfaceClass,
+ cn,
+ Send,
+ Tooltip,
+ toast,
+} from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import dynamic from 'next/dynamic'
+import { useRouter } from 'next/navigation'
+import { AddPeopleModal } from '@/components/permissions'
+import { SkillTile } from '@/app/workspace/[workspaceId]/components'
+import {
+ CredentialDetailHeading,
+ CredentialDetailLayout,
+ DetailSection,
+ UnsavedChangesModal,
+ useUnsavedChangesGuard,
+} from '@/app/workspace/[workspaceId]/components/credential-detail'
+import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card'
+import { useSkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members'
+import {
+ isSkillNameConflictError,
+ parseSkillMarkdown,
+ validateSkillName,
+} from '@/app/workspace/[workspaceId]/skills/components/utils'
+import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills'
+
+const RichMarkdownField = dynamic(
+ () =>
+ import(
+ '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
+ ).then((m) => m.RichMarkdownField),
+ {
+ ssr: false,
+ loading: () =>
,
+ }
+)
+
+const logger = createLogger('SkillDetail')
+
+interface FieldErrors {
+ name?: string
+ description?: string
+ content?: string
+}
+
+interface FieldLockTooltipProps {
+ reason: string | null
+ children: ReactNode
+}
+
+/**
+ * Wraps a read-only field so hovering it explains why editing is locked.
+ * Renders children unchanged when the field is editable. The wrapper div
+ * receives the hover events a disabled control swallows.
+ */
+function FieldLockTooltip({ reason, children }: FieldLockTooltipProps) {
+ if (!reason) return <>{children}>
+ return (
+
+
+ {children}
+
+ {reason}
+
+ )
+}
+
+interface SkillDetailProps {
+ workspaceId: string
+ skillId: string
+}
+
+/**
+ * Full-page skill detail, mirroring the integration credential detail surface:
+ * a fixed action bar (Share / Delete / Save), a heading, editable Name /
+ * Description / Content sections, and the Skill Editors roster. Non-editors
+ * and built-in template skills render read-only.
+ */
+export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
+ const router = useRouter()
+ const skillsHref = `/workspace/${workspaceId}/skills`
+
+ const { data: skills = [], isPending: skillsLoading } = useSkills(workspaceId)
+ const updateSkill = useUpdateSkill()
+ const deleteSkill = useDeleteSkill()
+ const skill = skills.find((s) => s.id === skillId) ?? null
+ const isBuiltin = !!skill?.readOnly
+ const editors = useSkillEditorsController({
+ skillId,
+ workspaceId,
+ // Built-ins have no editors; skip the roster fetch (it would 404).
+ enabled: !!skill && !isBuiltin,
+ })
+ const canEdit = !isBuiltin && !!skill?.canEdit
+
+ const [nameDraft, setNameDraft] = useState('')
+ const [descriptionDraft, setDescriptionDraft] = useState('')
+ const [contentDraft, setContentDraft] = useState('')
+ /** Bumped to remount the seed-once rich Content editor on programmatic sets. */
+ const [contentSeed, setContentSeed] = useState(0)
+ const [errors, setErrors] = useState
({})
+ const [shareOpen, setShareOpen] = useState(false)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const [prevSkillId, setPrevSkillId] = useState(null)
+
+ // Seed drafts when the skill first resolves (or the route id changes); a
+ // background refetch of the same skill must not clobber an in-progress edit.
+ if (skill && skill.id !== prevSkillId) {
+ setPrevSkillId(skill.id)
+ setNameDraft(skill.name)
+ setDescriptionDraft(skill.description)
+ setContentDraft(skill.content)
+ setErrors({})
+ setContentSeed((seed) => seed + 1)
+ }
+
+ const isDirty =
+ !!skill &&
+ !isBuiltin &&
+ (nameDraft !== skill.name ||
+ descriptionDraft !== skill.description ||
+ contentDraft !== skill.content)
+
+ const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref })
+
+ const handleSave = async () => {
+ if (!skill || !canEdit || !isDirty || updateSkill.isPending) return
+
+ const newErrors: FieldErrors = {}
+ const nameError = validateSkillName(nameDraft)
+ if (nameError) newErrors.name = nameError
+ if (!descriptionDraft.trim()) newErrors.description = 'Description is required'
+ if (!contentDraft.trim()) newErrors.content = 'Content is required'
+ if (Object.keys(newErrors).length > 0) {
+ setErrors(newErrors)
+ return
+ }
+
+ try {
+ // Partial update: only the fields that changed go over the wire.
+ await updateSkill.mutateAsync({
+ workspaceId,
+ skillId: skill.id,
+ updates: {
+ ...(nameDraft !== skill.name ? { name: nameDraft } : {}),
+ ...(descriptionDraft !== skill.description ? { description: descriptionDraft } : {}),
+ ...(contentDraft !== skill.content ? { content: contentDraft } : {}),
+ },
+ })
+ setErrors({})
+ } catch (error) {
+ if (isSkillNameConflictError(error)) {
+ setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') })
+ } else {
+ toast.error("Couldn't save skill", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ }
+ logger.error('Failed to save skill', error)
+ }
+ }
+
+ const handleConfirmDelete = async () => {
+ if (!skill) return
+ setShowDeleteConfirm(false)
+ try {
+ await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id })
+ router.push(skillsHref)
+ } catch (error) {
+ logger.error('Failed to delete skill', error)
+ }
+ }
+
+ /**
+ * Pasting a full SKILL.md destructures it into the fields. Gated on a real
+ * YAML `name:` key so a stray `---` or heading-only snippet pastes as
+ * ordinary content instead of silently overwriting all three fields.
+ */
+ const handleContentPaste = (text: string): boolean => {
+ const parsed = parseSkillMarkdown(text)
+ if (!parsed.nameFromFrontmatter) return false
+ setNameDraft(parsed.name)
+ setDescriptionDraft(parsed.description)
+ setContentDraft(parsed.content)
+ setErrors({})
+ setContentSeed((seed) => seed + 1)
+ return true
+ }
+
+ const back = (
+
+ Skills
+
+ )
+
+ const actions =
+ skill && canEdit ? (
+ <>
+ setShareOpen(true)}>
+ Share
+
+ setShowDeleteConfirm(true)} disabled={deleteSkill.isPending}>
+ Delete
+
+
+ {updateSkill.isPending ? 'Saving...' : 'Save'}
+
+ >
+ ) : null
+
+ if (skillsLoading && !skill) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ if (!skill) {
+ return (
+
+ Skill not found.
+
+ )
+ }
+
+ const readOnly = isBuiltin || !canEdit
+ const lockReason = !readOnly
+ ? null
+ : isBuiltin
+ ? 'Built-in skills are read-only'
+ : 'You need to be a skill editor to edit this skill'
+
+ return (
+ <>
+
+ }
+ title={skill.name}
+ subtitle={isBuiltin ? 'Built-in skill' : skill.description}
+ />
+
+
+
+ {
+ setNameDraft(event.target.value)
+ if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }))
+ }}
+ placeholder='my-skill-name'
+ autoComplete='off'
+ data-lpignore='true'
+ disabled={readOnly}
+ error={!!errors.name}
+ />
+
+ {errors.name && (
+ {errors.name}
+ )}
+
+
+
+
+ {
+ setDescriptionDraft(event.target.value)
+ if (errors.description) setErrors((prev) => ({ ...prev, description: undefined }))
+ }}
+ placeholder='What this skill does and when to use it...'
+ maxLength={1024}
+ autoComplete='off'
+ data-lpignore='true'
+ disabled={readOnly}
+ />
+
+ {errors.description && (
+ {errors.description}
+ )}
+
+
+
+
+ {
+ setContentDraft(value)
+ if (errors.content) setErrors((prev) => ({ ...prev, content: undefined }))
+ }}
+ placeholder='Skill instructions in markdown...'
+ minHeight={260}
+ disabled={readOnly}
+ error={!!errors.content}
+ workspaceId={workspaceId}
+ onPasteText={handleContentPaste}
+ />
+
+ {errors.content && (
+ {errors.content}
+ )}
+
+
+ {!isBuiltin && }
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts
index a3408dbd9eb..425a026a858 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts
@@ -1 +1,2 @@
export { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import/skill-import'
+export { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button'
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx
new file mode 100644
index 00000000000..02d7e616262
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import { useRef, useState } from 'react'
+import { Chip, Loader, toast } from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import {
+ type ParsedSkill,
+ readSkillFile,
+ SKILL_IMPORT_ACCEPT,
+} from '@/app/workspace/[workspaceId]/skills/components/utils'
+
+interface SkillImportButtonProps {
+ onImport: (data: ParsedSkill) => void
+ disabled?: boolean
+}
+
+/**
+ * Header action that imports an existing SKILL.md into the create form. Opens
+ * the OS file picker directly — no field on the page — and reports failures as
+ * a toast, since the action bar has no room for an inline error.
+ */
+export function SkillImportButton({ onImport, disabled = false }: SkillImportButtonProps) {
+ const inputRef = useRef(null)
+ const [importing, setImporting] = useState(false)
+
+ const handleFile = async (file: File) => {
+ setImporting(true)
+ try {
+ onImport(await readSkillFile(file))
+ } catch (error) {
+ toast.error("Couldn't import skill", {
+ description: getErrorMessage(error, 'Please try a .md or .zip file.'),
+ })
+ } finally {
+ setImporting(false)
+ }
+ }
+
+ return (
+ <>
+ inputRef.current?.click()} disabled={disabled || importing}>
+ {importing ? : 'Import'}
+
+ {
+ const file = event.target.files?.[0]
+ event.target.value = ''
+ if (file) void handleFile(file)
+ }}
+ />
+ >
+ )
+}
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 a49e5c2280a..4a06071c85b 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,156 +1,52 @@
'use client'
-import { useCallback, useState } from 'react'
-import { Chip, ChipInput, ChipModalField, Loader } from '@sim/emcn'
+import { useState } from 'react'
+import { ChipModalField } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
-import { requestJson } from '@/lib/api/client/request'
-import { importSkillContract } from '@/lib/api/contracts'
import {
- extractSkillFromZip,
- parseSkillMarkdown,
+ type ParsedSkill,
+ readSkillFile,
+ SKILL_IMPORT_ACCEPT,
} from '@/app/workspace/[workspaceId]/skills/components/utils'
-interface ImportedSkill {
- name: string
- description: string
- content: string
-}
-
interface SkillImportProps {
- onImport: (data: ImportedSkill) => void
-}
-
-type ImportState = 'idle' | 'loading' | 'error'
-
-const ACCEPTED_EXTENSIONS = ['.md', '.zip']
-
-function isAcceptedFile(file: File): boolean {
- const name = file.name.toLowerCase()
- return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
+ onImport: (data: ParsedSkill) => void
}
+/**
+ * The canvas modal's Import tab: a single file field that reads a SKILL.md (or
+ * a ZIP containing one) into the create form. The full-page create surface uses
+ * {@link SkillImportButton} in its action bar instead of a field.
+ */
export function SkillImport({ onImport }: SkillImportProps) {
- const [githubUrl, setGithubUrl] = useState('')
- const [githubState, setGithubState] = useState('idle')
- const [githubError, setGithubError] = useState('')
+ const [importing, setImporting] = useState(false)
+ const [error, setError] = useState('')
- const [fileState, setFileState] = useState('idle')
- const [fileError, setFileError] = useState('')
-
- const handleGithubImport = useCallback(async () => {
- const trimmed = githubUrl.trim()
- if (!trimmed) {
- setGithubError('Please enter a GitHub URL')
- setGithubState('error')
- return
- }
-
- setGithubState('loading')
- setGithubError('')
+ const handleFiles = async (files: File[]) => {
+ const file = files[0]
+ if (!file) return
+ setImporting(true)
+ setError('')
try {
- const data = await requestJson(importSkillContract, { body: { url: trimmed } })
- const parsed = parseSkillMarkdown(data.content)
- setGithubState('idle')
- onImport(parsed)
+ onImport(await readSkillFile(file))
} catch (err) {
- const message = getErrorMessage(err, 'Failed to import from GitHub')
- setGithubError(message)
- setGithubState('error')
+ setError(getErrorMessage(err, 'Failed to process file'))
+ } finally {
+ setImporting(false)
}
- }, [githubUrl, onImport])
-
- const processFile = useCallback(
- async (file: File) => {
- if (!isAcceptedFile(file)) {
- setFileError('Unsupported file type. Use .md or .zip files.')
- setFileState('error')
- return
- }
-
- setFileState('loading')
- setFileError('')
-
- try {
- let rawContent: string
-
- if (file.name.toLowerCase().endsWith('.zip')) {
- if (file.size > 5 * 1024 * 1024) {
- setFileError('ZIP file is too large (max 5 MB)')
- setFileState('error')
- return
- }
- rawContent = await extractSkillFromZip(file)
- } else {
- rawContent = await file.text()
- }
-
- const parsed = parseSkillMarkdown(rawContent)
- setFileState('idle')
- onImport(parsed)
- } catch (err) {
- const message = getErrorMessage(err, 'Failed to process file')
- setFileError(message)
- setFileState('error')
- }
- },
- [onImport]
- )
-
- const handleFiles = useCallback(
- (files: File[]) => {
- const file = files[0]
- if (file) processFile(file)
- },
- [processFile]
- )
-
- return (
-
-
-
- {
- setGithubUrl(e.target.value)
- if (githubError) setGithubError('')
- }}
- disabled={githubState === 'loading'}
- className='min-w-0 flex-1'
- />
-
- {githubState === 'loading' ? : 'Fetch'}
-
-
-
-
-
-
-
-
- )
-}
+ }
-function ImportDivider() {
return (
-
+ void handleFiles(files)}
+ loading={importing}
+ label={importing ? 'Importing…' : undefined}
+ description='.md file with YAML frontmatter, or .zip containing a SKILL.md'
+ error={error || undefined}
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts
new file mode 100644
index 00000000000..18d6f7716c6
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts
@@ -0,0 +1,4 @@
+export {
+ type SkillEditorsController,
+ useSkillEditorsController,
+} from './use-skill-editors-controller'
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts
new file mode 100644
index 00000000000..bcc2e6483fa
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts
@@ -0,0 +1,76 @@
+'use client'
+
+import { useCallback, useMemo } from 'react'
+import { createLogger } from '@sim/logger'
+import type { AddPeopleTarget } from '@/components/permissions'
+import type { SkillEditor } from '@/lib/api/contracts'
+import { useRemoveSkillMember, useSkillMembers, useUpsertSkillMember } from '@/hooks/queries/skills'
+
+const logger = createLogger('SkillEditorsController')
+
+export interface SkillEditorsController {
+ editors: SkillEditor[]
+ editorsLoading: boolean
+ editorsError: boolean
+ /** Lowercased emails already on the roster (incl. workspace admins) — feeds the Add People modal. */
+ existingEditorEmails: Set
+ addEditor: (target: AddPeopleTarget) => Promise
+ removeEditor: (userId: string) => Promise
+}
+
+interface UseSkillEditorsControllerParams {
+ skillId: string
+ workspaceId: string
+ /** Gate the roster fetch off (e.g. built-in template skills have no editors). */
+ enabled?: boolean
+}
+
+/**
+ * Data + mutation controller behind the skill editor surfaces (the detail
+ * page's Skill Editors section and the Share modal): exposes the roster —
+ * explicit editors plus derived workspace admins — and the add/remove actions.
+ * Renderers own only chrome.
+ */
+export function useSkillEditorsController({
+ skillId,
+ workspaceId,
+ enabled = true,
+}: UseSkillEditorsControllerParams): SkillEditorsController {
+ const {
+ data: editors = [],
+ isPending: editorsLoading,
+ isError: editorsError,
+ } = useSkillMembers(skillId, { enabled })
+ const { mutateAsync: upsertEditorAsync } = useUpsertSkillMember()
+ const { mutateAsync: removeEditorAsync } = useRemoveSkillMember()
+
+ const existingEditorEmails = useMemo(
+ () => new Set(editors.map((editor) => (editor.userEmail ?? '').toLowerCase()).filter(Boolean)),
+ [editors]
+ )
+
+ const addEditor = useCallback(
+ (target: AddPeopleTarget) => upsertEditorAsync({ skillId, workspaceId, userId: target.userId }),
+ [upsertEditorAsync, skillId, workspaceId]
+ )
+
+ const removeEditor = useCallback(
+ async (userId: string) => {
+ try {
+ await removeEditorAsync({ skillId, workspaceId, userId })
+ } catch (error) {
+ logger.error('Failed to remove skill editor', error)
+ }
+ },
+ [removeEditorAsync, skillId, workspaceId]
+ )
+
+ return {
+ editors,
+ editorsLoading,
+ editorsError,
+ existingEditorEmails,
+ addEditor,
+ removeEditor,
+ }
+}
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 a8b6e2fb4dd..cd6d8d29f78 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
@@ -12,10 +12,15 @@ import {
chipFieldSurfaceClass,
cn,
} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
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 {
+ isSkillNameConflictError,
+ parseSkillMarkdown,
+ validateSkillName,
+} from '@/app/workspace/[workspaceId]/skills/components/utils'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
@@ -34,12 +39,9 @@ interface SkillModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSave: () => void
- onDelete?: (skillId: string) => void
initialValues?: SkillDefinition
}
-const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
-
interface FieldErrors {
name?: string
description?: string
@@ -49,13 +51,12 @@ interface FieldErrors {
type TabValue = 'create' | 'import'
-export function SkillModal({
- open,
- onOpenChange,
- onSave,
- onDelete,
- initialValues,
-}: SkillModalProps) {
+const CREATE_TABS = [
+ { value: 'create', label: 'Create' },
+ { value: 'import', label: 'Import' },
+] as const
+
+export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -98,13 +99,8 @@ export function SkillModal({
const handleSave = async () => {
const newErrors: FieldErrors = {}
- if (!name.trim()) {
- newErrors.name = 'Name is required'
- } else if (name.length > 64) {
- newErrors.name = 'Name must be 64 characters or less'
- } else if (!KEBAB_CASE_REGEX.test(name)) {
- newErrors.name = 'Name must be kebab-case (e.g. my-skill)'
- }
+ const nameError = validateSkillName(name)
+ if (nameError) newErrors.name = nameError
if (!description.trim()) {
newErrors.description = 'Description is required'
@@ -136,11 +132,11 @@ export function SkillModal({
}
onSave()
} catch (error) {
- const message =
- error instanceof Error && error.message.includes('already exists')
- ? error.message
- : 'Failed to save skill. Please try again.'
- setErrors({ general: message })
+ if (isSkillNameConflictError(error)) {
+ setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') })
+ } else {
+ setErrors({ general: 'Failed to save skill. Please try again.' })
+ }
} finally {
setSaving(false)
}
@@ -172,8 +168,11 @@ export function SkillModal({
}
const isEditing = !!initialValues
- const readOnly = !!initialValues?.readOnly
- const showFooter = activeTab === 'create' || isEditing
+ const isBuiltin = !!initialValues?.readOnly
+ /** New skills are created by the actor (who becomes an editor); existing ones require editor access. */
+ const canEditSkill = !initialValues || initialValues.canEdit
+ const readOnly = isBuiltin || (isEditing && !canEditSkill)
+ const showFooter = activeTab === 'create'
return (
{!isEditing && (
setActiveTab(value as TabValue)}
/>
@@ -213,6 +209,7 @@ export function SkillModal({
required
error={errors.name}
hint='Lowercase letters, numbers, and hyphens (e.g. my-skill)'
+ disabled={readOnly || saving}
/>
@@ -241,6 +239,7 @@ export function SkillModal({
}}
placeholder='Skill instructions in markdown...'
minHeight={200}
+ maxHeight={360}
disabled={readOnly || saving}
error={!!errors.content}
workspaceId={workspaceId}
@@ -258,19 +257,7 @@ export function SkillModal({
{showFooter && (
onOpenChange(false)}
- cancelDisabled={readOnly}
- secondaryActions={
- isEditing && onDelete
- ? [
- {
- label: 'Delete',
- onClick: () => onDelete(initialValues.id),
- variant: 'destructive',
- disabled: readOnly,
- },
- ]
- : undefined
- }
+ cancelDisabled={isBuiltin}
primaryAction={{
label: saving ? 'Saving...' : isEditing ? 'Update' : 'Create',
onClick: handleSave,
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts
index debefcf690f..bdb862bf939 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts
@@ -1,6 +1,7 @@
import JSZip from 'jszip'
+import { isApiClientError } from '@/lib/api/client/errors'
-interface ParsedSkill {
+export interface ParsedSkill {
name: string
description: string
content: string
@@ -86,7 +87,6 @@ function inferNameFromHeading(markdown: string): string {
/**
* Extracts the SKILL.md content from a ZIP archive.
* Searches for a file named SKILL.md at any depth within the archive.
- * Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports).
*/
export async function extractSkillFromZip(
data: File | Blob | ArrayBuffer | Uint8Array
@@ -113,3 +113,56 @@ export async function extractSkillFromZip(
const content = await zip.file(candidates[0])!.async('string')
return content
}
+const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
+
+/**
+ * Validates a skill name against the same rules the API enforces
+ * (`skillNameSchema`). Returns the field message, or null when valid. Shared so
+ * the rule and its copy live in one place across every skill editing surface.
+ */
+export function validateSkillName(name: string): string | null {
+ if (!name.trim()) return 'Name is required'
+ if (name.length > 64) return 'Name must be 64 characters or less'
+ if (!KEBAB_CASE_REGEX.test(name)) return 'Name must be kebab-case (e.g. my-skill)'
+ return null
+}
+
+const SKILL_IMPORT_EXTENSIONS = ['.md', '.zip'] as const
+
+/** ZIPs are read fully in memory to find the SKILL.md, so cap what we'll accept. */
+const MAX_SKILL_ZIP_BYTES = 5 * 1024 * 1024
+
+/** `accept` attribute for the skill file pickers. */
+export const SKILL_IMPORT_ACCEPT = SKILL_IMPORT_EXTENSIONS.join(',')
+
+/**
+ * Reads a user-picked `.md` or `.zip` into structured skill fields — the shared
+ * path behind every import entry point (the create page's Import action and the
+ * canvas modal's Import tab). Throws a user-facing message on an unsupported
+ * extension, an oversized ZIP, or a ZIP with no SKILL.md inside.
+ */
+export async function readSkillFile(file: File): Promise {
+ const name = file.name.toLowerCase()
+
+ if (!SKILL_IMPORT_EXTENSIONS.some((ext) => name.endsWith(ext))) {
+ throw new Error('Unsupported file type. Use .md or .zip files.')
+ }
+
+ if (name.endsWith('.zip')) {
+ if (file.size > MAX_SKILL_ZIP_BYTES) {
+ throw new Error('ZIP file is too large (max 5 MB)')
+ }
+ return parseSkillMarkdown(await extractSkillFromZip(file))
+ }
+
+ return parseSkillMarkdown(await file.text())
+}
+
+/**
+ * Whether a skill save failed on the per-workspace unique-name constraint
+ * (HTTP 409). Surfaces as an inline Name-field error at the callsites; the
+ * server message names the conflicting skill.
+ */
+export function isSkillNameConflictError(error: unknown): boolean {
+ return isApiClientError(error) && error.status === 409
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx
new file mode 100644
index 00000000000..e8d291584a7
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+import { SkillCreate } from '@/app/workspace/[workspaceId]/skills/new/skill-create'
+
+export const metadata: Metadata = {
+ title: 'New Skill',
+}
+
+export default async function SkillCreatePage({
+ params,
+}: {
+ params: Promise<{ workspaceId: string }>
+}) {
+ const { workspaceId } = await params
+ return
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
new file mode 100644
index 00000000000..6c4a246f47b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx
@@ -0,0 +1,237 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Chip,
+ ChipInput,
+ ChipLink,
+ ChipTextarea,
+ chipFieldSurfaceClass,
+ cn,
+ toast,
+} from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import dynamic from 'next/dynamic'
+import { useRouter } from 'next/navigation'
+import { SkillTile } from '@/app/workspace/[workspaceId]/components'
+import {
+ CredentialDetailHeading,
+ CredentialDetailLayout,
+ DetailSection,
+ UnsavedChangesModal,
+ useUnsavedChangesGuard,
+} from '@/app/workspace/[workspaceId]/components/credential-detail'
+import { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import'
+import {
+ isSkillNameConflictError,
+ type ParsedSkill,
+ parseSkillMarkdown,
+ validateSkillName,
+} from '@/app/workspace/[workspaceId]/skills/components/utils'
+import { useCreateSkill } from '@/hooks/queries/skills'
+
+const RichMarkdownField = dynamic(
+ () =>
+ import(
+ '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field'
+ ).then((m) => m.RichMarkdownField),
+ {
+ ssr: false,
+ loading: () => ,
+ }
+)
+
+const logger = createLogger('SkillCreate')
+
+interface FieldErrors {
+ name?: string
+ description?: string
+ content?: string
+}
+
+interface SkillCreateProps {
+ workspaceId: string
+}
+
+/**
+ * Full-page skill creation, mirroring the skill detail surface: a fixed action
+ * bar (Import / Create skill), a heading, and the editable Name / Description /
+ * Content sections. Importing a SKILL.md prefills all three fields in place.
+ */
+export function SkillCreate({ workspaceId }: SkillCreateProps) {
+ const router = useRouter()
+ const skillsHref = `/workspace/${workspaceId}/skills`
+
+ const createSkill = useCreateSkill()
+
+ const [nameDraft, setNameDraft] = useState('')
+ const [descriptionDraft, setDescriptionDraft] = useState('')
+ const [contentDraft, setContentDraft] = useState('')
+ /** Bumped to remount the seed-once rich Content editor on programmatic sets. */
+ const [contentSeed, setContentSeed] = useState(0)
+ const [errors, setErrors] = useState({})
+
+ // Drops on success so the guard pops its history sentinel before we navigate —
+ // otherwise Back from the new skill lands on a stale, empty create form.
+ const isDirty =
+ !createSkill.isSuccess &&
+ (!!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim())
+
+ const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref })
+
+ const handleCreate = async () => {
+ if (createSkill.isPending) return
+
+ const newErrors: FieldErrors = {}
+ const nameError = validateSkillName(nameDraft)
+ if (nameError) newErrors.name = nameError
+ if (!descriptionDraft.trim()) newErrors.description = 'Description is required'
+ if (!contentDraft.trim()) newErrors.content = 'Content is required'
+ if (Object.keys(newErrors).length > 0) {
+ setErrors(newErrors)
+ return
+ }
+
+ try {
+ const created = await createSkill.mutateAsync({
+ workspaceId,
+ skill: { name: nameDraft, description: descriptionDraft, content: contentDraft },
+ })
+ setErrors({})
+ // The upsert responds with the caller's whole skill list (built-ins
+ // included), not just the new row — match by name, which is unique per
+ // workspace, rather than trusting the first element.
+ const createdId = created.find((skill) => skill.name === nameDraft)?.id
+ router.push(createdId ? `${skillsHref}/${createdId}` : skillsHref)
+ } catch (error) {
+ if (isSkillNameConflictError(error)) {
+ setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') })
+ } else {
+ toast.error("Couldn't create skill", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ }
+ logger.error('Failed to create skill', error)
+ }
+ }
+
+ const applyImportedSkill = (data: ParsedSkill) => {
+ setNameDraft(data.name)
+ setDescriptionDraft(data.description)
+ setContentDraft(data.content)
+ setErrors({})
+ setContentSeed((seed) => seed + 1)
+ }
+
+ /**
+ * Pasting a full SKILL.md destructures it into the fields. Gated on a real
+ * YAML `name:` key so a stray `---` or heading-only snippet pastes as
+ * ordinary content instead of silently overwriting all three fields.
+ */
+ const handleContentPaste = (text: string): boolean => {
+ const parsed = parseSkillMarkdown(text)
+ if (!parsed.nameFromFrontmatter) return false
+ applyImportedSkill(parsed)
+ return true
+ }
+
+ const back = (
+
+ Skills
+
+ )
+
+ const actions = (
+ <>
+
+
+ {createSkill.isPending ? 'Creating...' : 'Create'}
+
+ >
+ )
+
+ return (
+ <>
+
+ }
+ title='New skill'
+ subtitle='Write a skill, or import an existing SKILL.md'
+ />
+
+
+ {
+ setNameDraft(event.target.value)
+ if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }))
+ }}
+ placeholder='my-skill-name'
+ autoComplete='off'
+ data-lpignore='true'
+ disabled={createSkill.isPending}
+ error={!!errors.name}
+ />
+
+ {errors.name ?? 'Lowercase letters, numbers, and hyphens (e.g. my-skill)'}
+
+
+
+
+ {
+ setDescriptionDraft(event.target.value)
+ if (errors.description) setErrors((prev) => ({ ...prev, description: undefined }))
+ }}
+ placeholder='What this skill does and when to use it...'
+ maxLength={1024}
+ autoComplete='off'
+ data-lpignore='true'
+ disabled={createSkill.isPending}
+ error={!!errors.description}
+ />
+ {errors.description && (
+ {errors.description}
+ )}
+
+
+
+ {
+ setContentDraft(value)
+ if (errors.content) setErrors((prev) => ({ ...prev, content: undefined }))
+ }}
+ placeholder='Skill instructions in markdown...'
+ minHeight={260}
+ disabled={createSkill.isPending}
+ error={!!errors.content}
+ workspaceId={workspaceId}
+ onPasteText={handleContentPaste}
+ />
+ {errors.content && (
+ {errors.content}
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts
index 554005fc76d..e78b8167fc5 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts
@@ -3,20 +3,18 @@ import { parseAsString } from 'nuqs/server'
/**
* Co-located, typed URL query-param definition for the Skills gallery.
*
- * `skillId` deep-links the skill edit modal to a specific skill. The modal
- * opens when a valid id (one that resolves to a loaded skill) is present;
- * closing it clears the param. Opening a skill is a destination, so it lands in
- * the browser history (`history: 'push'`). The "create new skill" flow has no id
- * and stays in local component state.
+ * `skillId` is a LEGACY deep-link param from when skills edited in a modal on
+ * this page. Skills now open at `/workspace/[workspaceId]/skills/[skillId]`;
+ * the gallery reads this param once and redirects there (read-then-strip).
*/
export const skillIdParam = {
key: 'skillId',
parser: parseAsString,
} as const
-/** Opening a skill is a destination → push to history; clear on close. */
+/** Read-once redirect signal — replace history, never linger. */
export const skillIdUrlKeys = {
- history: 'push',
+ history: 'replace',
clearOnDefault: true,
} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
index d045bf88b1b..7004a6c3ebf 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
@@ -1,27 +1,23 @@
'use client'
-import { useState } from 'react'
-import { Chip, ChipConfirmModal, ChipInput, Search } from '@sim/emcn'
-import { createLogger } from '@sim/logger'
+import { useEffect, useRef } from 'react'
+import { Chip, ChipInput, Search } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight, Plus } from 'lucide-react'
-import { useParams } from 'next/navigation'
+import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
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'
-import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal'
import {
skillIdParam,
skillIdUrlKeys,
skillSearchParam,
skillSearchUrlKeys,
} from '@/app/workspace/[workspaceId]/skills/search-params'
-import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
+import { useSkills } from '@/hooks/queries/skills'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
-const logger = createLogger('SkillsSettings')
-
const SKILLS_LABEL = 'Skills'
interface SkillItemProps {
@@ -68,22 +64,31 @@ function SkillSection({ label, children }: SkillSectionProps) {
export function Skills() {
const params = useParams()
+ const router = useRouter()
const workspaceId = (params?.workspaceId as string) || ''
+ const skillsHref = `/workspace/${workspaceId}/skills`
const { data: skills = [], isLoading, error } = useSkills(workspaceId)
- const deleteSkillMutation = useDeleteSkill()
const [searchTerm, setSearchTermParam] = useQueryState(skillSearchParam.key, {
...skillSearchParam.parser,
...skillSearchUrlKeys,
})
- const [editingSkillId, setEditingSkillId] = useQueryState(skillIdParam.key, {
+ const [legacySkillId, setLegacySkillId] = useQueryState(skillIdParam.key, {
...skillIdParam.parser,
...skillIdUrlKeys,
})
- const [showAddForm, setShowAddForm] = useState(false)
- const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null)
- const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ /**
+ * Legacy deep links opened the edit modal via `?skillId=`; skills now have a
+ * dedicated detail page. Redirect once, stripping the param.
+ */
+ const redirectedLegacyId = useRef(false)
+ useEffect(() => {
+ if (!legacySkillId || redirectedLegacyId.current) return
+ redirectedLegacyId.current = true
+ setLegacySkillId(null, { history: 'replace' })
+ router.replace(`${skillsHref}/${legacySkillId}`)
+ }, [legacySkillId, setLegacySkillId, router, skillsHref])
/**
* The input is controlled directly by the instant nuqs value; only the URL
@@ -92,9 +97,6 @@ export function Skills() {
*/
const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam)
- /** Derive the skill being edited from the loaded list — never store the object in the URL. */
- const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null
-
const filteredSkills = skills.filter((s) => {
if (!searchTerm.trim()) return true
const searchLower = searchTerm.trim().toLowerCase()
@@ -104,46 +106,15 @@ export function Skills() {
)
})
- const handleDeleteClick = (skillId: string) => {
- const s = skills.find((sk) => sk.id === skillId)
- if (!s) return
-
- setSkillToDelete({ id: skillId, name: s.name })
- setShowDeleteDialog(true)
- }
-
- const handleDeleteSkill = async () => {
- if (!skillToDelete) return
-
- setShowDeleteDialog(false)
-
- try {
- await deleteSkillMutation.mutateAsync({
- workspaceId,
- skillId: skillToDelete.id,
- })
- logger.info(`Deleted skill: ${skillToDelete.id}`)
- } catch (error) {
- logger.error('Error deleting skill:', error)
- } finally {
- setSkillToDelete(null)
- }
- }
-
- const handleSkillSaved = () => {
- setShowAddForm(false)
- setEditingSkillId(null)
- }
-
const showNoResults = searchTerm.trim() && filteredSkills.length === 0
- const handleOpenAddForm = () => {
- setEditingSkillId(null)
- setShowAddForm(true)
- }
-
const addButton = (
-
+ router.push(`${skillsHref}/new`)}
+ disabled={isLoading}
+ leftIcon={Plus}
+ >
Add to Sim
)
@@ -177,7 +148,7 @@ export function Skills() {
key={s.id}
name={s.name}
description={s.description}
- onClick={() => setEditingSkillId(s.id)}
+ onClick={() => router.push(`${skillsHref}/${s.id}`)}
/>
))}
@@ -189,38 +160,6 @@ export function Skills() {