feat(skills): added skills to agent block

This commit is contained in:
waleed
2026-02-05 17:40:47 -08:00
parent 1d4d61a10a
commit 84e77fe635
23 changed files with 12041 additions and 4 deletions

View File

@@ -0,0 +1,192 @@
import { db } from '@sim/db'
import { skill } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { upsertSkills } from '@/lib/workflows/skills/operations'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('SkillsAPI')
const SkillSchema = z.object({
skills: z.array(
z.object({
id: z.string().optional(),
name: z
.string()
.min(1, 'Skill name is required')
.max(64)
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)'),
description: z.string().min(1, 'Description is required').max(1024),
content: z.string().min(1, 'Content is required'),
})
),
workspaceId: z.string().optional(),
})
/** GET - Fetch all skills for a workspace */
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const searchParams = request.nextUrl.searchParams
const workspaceId = searchParams.get('workspaceId')
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized skills access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = authResult.userId
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId`)
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!userPermission) {
logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const result = await db
.select()
.from(skill)
.where(eq(skill.workspaceId, workspaceId))
.orderBy(desc(skill.createdAt))
return NextResponse.json({ data: result }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching skills:`, error)
return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 })
}
}
/** POST - Create or update skills */
export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized skills update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = authResult.userId
const body = await req.json()
try {
const { skills, workspaceId } = SkillSchema.parse(body)
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId in request body`)
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!userPermission) {
logger.warn(
`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
const resultSkills = await upsertSkills({
skills,
workspaceId,
userId,
requestId,
})
return NextResponse.json({ success: true, data: resultSkills })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid skills data`, {
errors: validationError.errors,
})
return NextResponse.json(
{ error: 'Invalid request data', details: validationError.errors },
{ status: 400 }
)
}
throw validationError
}
} catch (error) {
logger.error(`[${requestId}] Error updating skills`, error)
const errorMessage = error instanceof Error ? error.message : 'Failed to update skills'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
/** DELETE - Delete a skill by ID */
export async function DELETE(request: NextRequest) {
const requestId = generateRequestId()
const searchParams = request.nextUrl.searchParams
const skillId = searchParams.get('id')
const workspaceId = searchParams.get('workspaceId')
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized skill deletion attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = authResult.userId
if (!skillId) {
logger.warn(`[${requestId}] Missing skill ID for deletion`)
return NextResponse.json({ error: 'Skill ID is required' }, { status: 400 })
}
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId for deletion`)
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!userPermission) {
logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (userPermission !== 'admin' && userPermission !== 'write') {
logger.warn(
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
)
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
const existingSkill = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1)
if (existingSkill.length === 0) {
logger.warn(`[${requestId}] Skill not found: ${skillId}`)
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
}
if (existingSkill[0].workspaceId !== workspaceId) {
logger.warn(`[${requestId}] Skill ${skillId} does not belong to workspace ${workspaceId}`)
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
}
await db.delete(skill).where(eq(skill.id, skillId))
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error deleting skill:`, error)
return NextResponse.json({ error: 'Failed to delete skill' }, { status: 500 })
}
}

View File

@@ -24,6 +24,7 @@ export { ResponseFormat } from './response/response-format'
export { ScheduleInfo } from './schedule-info/schedule-info'
export { SheetSelectorInput } from './sheet-selector/sheet-selector-input'
export { ShortInput } from './short-input/short-input'
export { SkillInput } from './skill-input/skill-input'
export { SlackSelectorInput } from './slack-selector/slack-selector-input'
export { SliderInput } from './slider-input/slider-input'
export { InputFormat } from './starter/input-format'

View File

@@ -0,0 +1,180 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Plus, Sparkles, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useSkills } from '@/hooks/queries/skills'
import { usePermissionConfig } from '@/hooks/use-permission-config'
interface StoredSkill {
skillId: string
name?: string
}
interface SkillInputProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: unknown
disabled?: boolean
}
export function SkillInput({
blockId,
subBlockId,
isPreview,
previewValue,
disabled,
}: SkillInputProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { config: permissionConfig } = usePermissionConfig()
const { data: workspaceSkills = [] } = useSkills(workspaceId)
const [value, setValue] = useSubBlockValue<StoredSkill[]>(blockId, subBlockId)
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
const [open, setOpen] = useState(false)
const selectedSkills: StoredSkill[] = useMemo(() => {
if (isPreview && previewValue) {
return Array.isArray(previewValue) ? previewValue : []
}
return Array.isArray(value) ? value : []
}, [isPreview, previewValue, value])
const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills])
const skillsDisabled = permissionConfig.disableSkills
const skillGroups = useMemo((): ComboboxOptionGroup[] => {
const groups: ComboboxOptionGroup[] = []
if (!skillsDisabled) {
groups.push({
items: [
{
label: 'Create Skill',
value: 'action-create-skill',
icon: Plus,
onSelect: () => {
setShowCreateModal(true)
setOpen(false)
},
disabled: isPreview,
},
],
})
}
const availableSkills = workspaceSkills.filter((s) => !selectedIds.has(s.id))
if (availableSkills.length > 0) {
groups.push({
section: 'Skills',
items: availableSkills.map((s) => {
return {
label: s.name,
value: `skill-${s.id}`,
icon: Sparkles,
onSelect: () => {
const newSkills: StoredSkill[] = [...selectedSkills, { skillId: s.id, name: s.name }]
setValue(newSkills)
setOpen(false)
},
}
}),
})
}
return groups
}, [workspaceSkills, selectedIds, selectedSkills, setValue, isPreview, skillsDisabled])
const handleRemove = useCallback(
(skillId: string) => {
const newSkills = selectedSkills.filter((s) => s.skillId !== skillId)
setValue(newSkills)
},
[selectedSkills, setValue]
)
const handleSkillSaved = useCallback(() => {
setShowCreateModal(false)
setEditingSkill(null)
}, [])
const resolveSkillName = useCallback(
(stored: StoredSkill): string => {
const found = workspaceSkills.find((s) => s.id === stored.skillId)
return found?.name ?? stored.name ?? stored.skillId
},
[workspaceSkills]
)
return (
<>
<div className='w-full space-y-[8px]'>
<Combobox
options={[]}
groups={skillGroups}
placeholder='Add skill...'
disabled={disabled}
searchable
searchPlaceholder='Search skills...'
maxHeight={240}
emptyMessage='No skills found'
onOpenChange={setOpen}
/>
{selectedSkills.length > 0 && (
<div className='flex flex-wrap gap-[4px]'>
{selectedSkills.map((stored) => {
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
return (
<div
key={stored.skillId}
className='flex cursor-pointer items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[2px] font-medium text-[12px] text-[var(--text-secondary)] hover:bg-[var(--surface-6)]'
onClick={() => {
if (fullSkill && !disabled && !isPreview) {
setEditingSkill(fullSkill)
}
}}
>
<Sparkles className='h-[10px] w-[10px] text-[var(--text-tertiary)]' />
<span className='max-w-[140px] truncate'>{resolveSkillName(stored)}</span>
{!disabled && !isPreview && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleRemove(stored.skillId)
}}
className='ml-[2px] rounded-[2px] p-[1px] text-[var(--text-tertiary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-secondary)]'
>
<XIcon className='h-[10px] w-[10px]' />
</button>
)}
</div>
)
})}
</div>
)}
</div>
<SkillModal
open={showCreateModal || !!editingSkill}
onOpenChange={(isOpen) => {
if (!isOpen) {
setShowCreateModal(false)
setEditingSkill(null)
}
}}
onSave={handleSkillSaved}
initialValues={editingSkill ?? undefined}
/>
</>
)
}

View File

@@ -32,6 +32,7 @@ import {
ScheduleInfo,
SheetSelectorInput,
ShortInput,
SkillInput,
SlackSelectorInput,
SliderInput,
Switch,
@@ -687,6 +688,17 @@ function SubBlockComponent({
/>
)
case 'skill-input':
return (
<SkillInput
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
/>
)
case 'checkbox-list':
return (
<CheckboxList

View File

@@ -9,6 +9,7 @@ export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp'
export { Skills } from './skills/skills'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -0,0 +1,179 @@
'use client'
import type { ChangeEvent } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
interface SkillModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSave: () => void
initialValues?: SkillDefinition
}
const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const createSkill = useCreateSkill()
const updateSkill = useUpdateSkill()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [content, setContent] = useState('')
const [formError, setFormError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open) {
if (initialValues) {
setName(initialValues.name)
setDescription(initialValues.description)
setContent(initialValues.content)
} else {
setName('')
setDescription('')
setContent('')
}
setFormError('')
}
}, [open, initialValues])
const hasChanges = useMemo(() => {
if (!initialValues) return true
return (
name !== initialValues.name ||
description !== initialValues.description ||
content !== initialValues.content
)
}, [name, description, content, initialValues])
const handleSave = async () => {
if (!name.trim()) {
setFormError('Name is required')
return
}
if (name.length > 64) {
setFormError('Name must be 64 characters or less')
return
}
if (!KEBAB_CASE_REGEX.test(name)) {
setFormError('Name must be kebab-case (e.g. my-skill)')
return
}
if (!description.trim()) {
setFormError('Description is required')
return
}
if (!content.trim()) {
setFormError('Content is required')
return
}
setSaving(true)
try {
if (initialValues) {
await updateSkill.mutateAsync({
workspaceId,
skillId: initialValues.id,
updates: { name, description, content },
})
} else {
await createSkill.mutateAsync({
workspaceId,
skill: { name, description, content },
})
}
onSave()
} catch {
// Error is handled by React Query
} finally {
setSaving(false)
}
}
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='lg'>
<ModalHeader>{initialValues ? 'Edit Skill' : 'Create Skill'}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-name' className='font-medium text-[13px]'>
Name
</Label>
<Input
id='skill-name'
placeholder='my-skill-name'
value={name}
onChange={(e) => {
setName(e.target.value)
if (formError) setFormError('')
}}
/>
{formError && (
<span className='text-[11px] text-[var(--text-error)]'>{formError}</span>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-description' className='font-medium text-[13px]'>
Description
</Label>
<Input
id='skill-description'
placeholder='What this skill does and when to use it...'
value={description}
onChange={(e) => {
setDescription(e.target.value)
if (formError) setFormError('')
}}
maxLength={1024}
/>
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-content' className='font-medium text-[13px]'>
Content
</Label>
<Textarea
id='skill-content'
placeholder='Skill instructions in markdown...'
value={content}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
if (formError) setFormError('')
}}
className='min-h-[200px] resize-y font-mono text-[13px]'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,215 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
const logger = createLogger('SkillsSettings')
function SkillSkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
</div>
</div>
)
}
export function Skills() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { data: skills = [], isLoading, error, refetch: refetchSkills } = useSkills(workspaceId)
const deleteSkillMutation = useDeleteSkill()
const [searchTerm, setSearchTerm] = useState('')
const [deletingSkills, setDeletingSkills] = useState<Set<string>>(new Set())
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const filteredSkills = skills.filter((s) => {
if (!searchTerm.trim()) return true
const searchLower = searchTerm.toLowerCase()
return (
s.name.toLowerCase().includes(searchLower) ||
s.description.toLowerCase().includes(searchLower)
)
})
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
setDeletingSkills((prev) => new Set(prev).add(skillToDelete.id))
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 {
setDeletingSkills((prev) => {
const next = new Set(prev)
next.delete(skillToDelete.id)
return next
})
setSkillToDelete(null)
}
}
const handleSkillSaved = () => {
setShowAddForm(false)
setEditingSkill(null)
refetchSkills()
}
const hasSkills = skills && skills.length > 0
const showEmptyState = !hasSkills && !showAddForm && !editingSkill
const showNoResults = searchTerm.trim() && filteredSkills.length === 0 && skills.length > 0
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<div
className={cn(
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
isLoading && 'opacity-50'
)}
>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search skills...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={isLoading}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
/>
</div>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{error instanceof Error ? error.message : 'Failed to load skills'}
</p>
</div>
) : isLoading ? (
<div className='flex flex-col gap-[8px]'>
<SkillSkeleton />
<SkillSkeleton />
<SkillSkeleton />
</div>
) : showEmptyState ? (
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
Click "Add" above to get started
</div>
) : (
<div className='flex flex-col gap-[8px]'>
{filteredSkills.map((s) => (
<div key={s.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate font-medium text-[14px]'>{s.name}</span>
<p className='truncate text-[13px] text-[var(--text-muted)]'>{s.description}</p>
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Button variant='default' onClick={() => setEditingSkill(s)}>
Edit
</Button>
<Button
variant='ghost'
onClick={() => handleDeleteClick(s.id)}
disabled={deletingSkills.has(s.id)}
>
{deletingSkills.has(s.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
))}
{showNoResults && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No skills found matching "{searchTerm}"
</div>
)}
</div>
)}
</div>
</div>
<SkillModal
open={showAddForm || !!editingSkill}
onOpenChange={(open) => {
if (!open) {
setShowAddForm(false)
setEditingSkill(null)
}
}}
onSave={handleSkillSaved}
initialValues={editingSkill ?? undefined}
/>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalHeader>Delete Skill</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{skillToDelete?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDeleteSkill}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -13,6 +13,7 @@ import {
Server,
Settings,
ShieldCheck,
Sparkles,
User,
Users,
Wrench,
@@ -52,6 +53,7 @@ import {
General,
Integrations,
MCP,
Skills,
Subscription,
TeamManagement,
WorkflowMcpServers,
@@ -93,6 +95,7 @@ type SettingsSection =
| 'copilot'
| 'mcp'
| 'custom-tools'
| 'skills'
| 'workflow-mcp-servers'
| 'debug'
@@ -156,6 +159,7 @@ const allNavigationItems: NavigationItem[] = [
},
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'skills', label: 'Skills', icon: Sparkles, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
@@ -556,6 +560,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{effectiveActiveSection === 'copilot' && <Copilot />}
{effectiveActiveSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{effectiveActiveSection === 'custom-tools' && <CustomTools />}
{effectiveActiveSection === 'skills' && <Skills />}
{effectiveActiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveActiveSection === 'debug' && <Debug />}
</SModalMainBody>

View File

@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
// Use the real registry module, not the global mock from vitest.setup.ts
vi.unmock('@/blocks/registry')
import { generateRouterPrompt } from '@/blocks/blocks/router'
@@ -15,7 +14,7 @@ import {
} from '@/blocks/registry'
import { AuthMode } from '@/blocks/types'
describe('Blocks Module', () => {
describe.concurrent('Blocks Module', () => {
describe('Registry', () => {
it('should have a non-empty registry of blocks', () => {
expect(Object.keys(registry).length).toBeGreaterThan(0)
@@ -409,6 +408,7 @@ describe('Blocks Module', () => {
'workflow-input-mapper',
'text',
'router-input',
'skill-input',
]
const blocks = getAllBlocks()

View File

@@ -407,6 +407,12 @@ Return ONLY the JSON array.`,
type: 'tool-input',
defaultValue: [],
},
{
id: 'skills',
title: 'Skills',
type: 'skill-input',
defaultValue: [],
},
{
id: 'apiKey',
title: 'API Key',
@@ -769,6 +775,7 @@ Example 3 (Array Input):
description: 'Thinking level for models with extended thinking (Anthropic Claude, Gemini 3)',
},
tools: { type: 'json', description: 'Available tools configuration' },
skills: { type: 'json', description: 'Selected skills configuration' },
},
outputs: {
content: { type: 'string', description: 'Generated response content' },

View File

@@ -51,6 +51,7 @@ export type SubBlockType =
| 'code' // Code editor
| 'switch' // Toggle button
| 'tool-input' // Tool configuration
| 'skill-input' // Skill selection for agent blocks
| 'checkbox-list' // Multiple selection
| 'grouped-checkbox-list' // Grouped, scrollable checkbox list with select all
| 'condition-input' // Conditional logic

View File

@@ -43,6 +43,13 @@ export class CustomToolsNotAllowedError extends Error {
}
}
export class SkillsNotAllowedError extends Error {
constructor() {
super('Skills are not allowed based on your permission group settings')
this.name = 'SkillsNotAllowedError'
}
}
export class InvitationsNotAllowedError extends Error {
constructor() {
super('Invitations are not allowed based on your permission group settings')
@@ -201,6 +208,26 @@ export async function validateCustomToolsAllowed(
}
}
export async function validateSkillsAllowed(
userId: string | undefined,
ctx?: ExecutionContext
): Promise<void> {
if (!userId) {
return
}
const config = await getPermissionConfig(userId, ctx)
if (!config) {
return
}
if (config.disableSkills) {
logger.warn('Skills blocked by permission group', { userId })
throw new SkillsNotAllowedError()
}
}
/**
* Validates if the user is allowed to send invitations.
* Also checks the global feature flag.

View File

@@ -11,9 +11,15 @@ import {
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
validateSkillsAllowed,
} from '@/ee/access-control/utils/permission-check'
import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
import { memoryService } from '@/executor/handlers/agent/memory'
import {
buildLoadSkillTool,
buildSkillsSystemPromptSection,
resolveSkillMetadata,
} from '@/executor/handlers/agent/skills-resolver'
import type {
AgentInputs,
Message,
@@ -57,8 +63,21 @@ export class AgentBlockHandler implements BlockHandler {
const providerId = getProviderFromModel(model)
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
// Resolve skill metadata for progressive disclosure
const skillInputs = filteredInputs.skills ?? []
let skillMetadata: Array<{ name: string; description: string }> = []
if (skillInputs.length > 0 && ctx.workspaceId) {
await validateSkillsAllowed(ctx.userId, ctx)
skillMetadata = await resolveSkillMetadata(skillInputs, ctx.workspaceId)
if (skillMetadata.length > 0) {
const skillNames = skillMetadata.map((s) => s.name)
formattedTools.push(buildLoadSkillTool(skillNames))
}
}
const streamingConfig = this.getStreamingConfig(ctx, block)
const messages = await this.buildMessages(ctx, filteredInputs)
const messages = await this.buildMessages(ctx, filteredInputs, skillMetadata)
const providerRequest = this.buildProviderRequest({
ctx,
@@ -723,7 +742,8 @@ export class AgentBlockHandler implements BlockHandler {
private async buildMessages(
ctx: ExecutionContext,
inputs: AgentInputs
inputs: AgentInputs,
skillMetadata: Array<{ name: string; description: string }> = []
): Promise<Message[] | undefined> {
const messages: Message[] = []
const memoryEnabled = inputs.memoryType && inputs.memoryType !== 'none'
@@ -803,6 +823,20 @@ export class AgentBlockHandler implements BlockHandler {
messages.unshift(...systemMessages)
}
// 8. Inject skill metadata into the system message (progressive disclosure)
if (skillMetadata.length > 0) {
const skillSection = buildSkillsSystemPromptSection(skillMetadata)
const systemIdx = messages.findIndex((m) => m.role === 'system')
if (systemIdx >= 0) {
messages[systemIdx] = {
...messages[systemIdx],
content: messages[systemIdx].content + skillSection,
}
} else {
messages.unshift({ role: 'system', content: skillSection.trim() })
}
}
return messages.length > 0 ? messages : undefined
}

View File

@@ -0,0 +1,107 @@
import { db } from '@sim/db'
import { skill } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import type { SkillInput } from '@/executor/handlers/agent/types'
const logger = createLogger('SkillsResolver')
interface SkillMetadata {
name: string
description: string
}
/**
* Fetch skill metadata (name + description) for system prompt injection.
* Only returns lightweight data so the LLM knows what skills are available.
*/
export async function resolveSkillMetadata(
skillInputs: SkillInput[],
workspaceId: string
): Promise<SkillMetadata[]> {
if (!skillInputs.length || !workspaceId) return []
const skillIds = skillInputs.map((s) => s.skillId)
try {
const rows = await db
.select({ name: skill.name, description: skill.description })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds)))
return rows
} catch (error) {
logger.error('Failed to resolve skill metadata', { error, skillIds, workspaceId })
return []
}
}
/**
* Fetch full skill content for a load_skill tool response.
* Called when the LLM decides a skill is relevant and invokes load_skill.
*/
export async function resolveSkillContent(
skillName: string,
workspaceId: string
): Promise<string | null> {
if (!skillName || !workspaceId) return null
try {
const rows = await db
.select({ content: skill.content, name: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, skillName)))
.limit(1)
if (rows.length === 0) {
logger.warn('Skill not found', { skillName, workspaceId })
return null
}
return rows[0].content
} catch (error) {
logger.error('Failed to resolve skill content', { error, skillName, workspaceId })
return null
}
}
/**
* Build the system prompt section that lists available skills.
*/
export function buildSkillsSystemPromptSection(skills: SkillMetadata[]): string {
if (!skills.length) return ''
const skillList = skills.map((s) => `- ${s.name}: ${s.description}`).join('\n')
return [
'',
'You have access to the following skills. Use the load_skill tool to activate a skill when relevant.',
'',
'Available skills:',
skillList,
].join('\n')
}
/**
* Build the load_skill tool definition for injection into the tools array.
* Returns a ProviderToolConfig-compatible object so all providers can process it.
*/
export function buildLoadSkillTool(skillNames: string[]) {
return {
id: 'load_skill',
name: 'load_skill',
description: `Load a skill to get specialized instructions. Available skills: ${skillNames.join(', ')}`,
params: {},
parameters: {
type: 'object',
properties: {
skill_name: {
type: 'string',
description: 'Name of the skill to load',
enum: skillNames,
},
},
required: ['skill_name'],
},
}
}

View File

@@ -1,7 +1,14 @@
export interface SkillInput {
skillId: string
name?: string
description?: string
}
export interface AgentInputs {
model?: string
responseFormat?: string | object
tools?: ToolInput[]
skills?: SkillInput[]
// Legacy inputs (backward compatible)
systemPrompt?: string
userPrompt?: string | object

View File

@@ -0,0 +1,292 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getQueryClient } from '@/app/_shell/providers/query-provider'
const logger = createLogger('SkillsQueries')
const API_ENDPOINT = '/api/skills'
export interface SkillDefinition {
id: string
workspaceId: string | null
userId: string | null
name: string
description: string
content: string
createdAt: string
updatedAt?: string
}
/**
* Query key factories for skills queries
*/
export const skillsKeys = {
all: ['skills'] as const,
lists: () => [...skillsKeys.all, 'list'] as const,
list: (workspaceId: string) => [...skillsKeys.lists(), workspaceId] as const,
}
/**
* Extract workspaceId from the current URL path
*/
function getWorkspaceIdFromUrl(): string | null {
if (typeof window === 'undefined') return null
const match = window.location.pathname.match(/^\/workspace\/([^/]+)/)
return match?.[1] ?? null
}
/**
* Get all skills from the query cache (for non-React code)
*/
export function getSkills(workspaceId?: string): SkillDefinition[] {
if (typeof window === 'undefined') return []
const wsId = workspaceId ?? getWorkspaceIdFromUrl()
if (!wsId) return []
const queryClient = getQueryClient()
return queryClient.getQueryData<SkillDefinition[]>(skillsKeys.list(wsId)) ?? []
}
/**
* Get a specific skill from the query cache by ID or name
*/
export function getSkill(identifier: string, workspaceId?: string): SkillDefinition | undefined {
const skills = getSkills(workspaceId)
return skills.find((s) => s.id === identifier || s.name === identifier)
}
/**
* Fetch skills for a workspace
*/
async function fetchSkills(workspaceId: string): Promise<SkillDefinition[]> {
const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `Failed to fetch skills: ${response.statusText}`)
}
const { data } = await response.json()
if (!Array.isArray(data)) {
throw new Error('Invalid response format')
}
return data.map((s: Record<string, unknown>) => ({
id: s.id as string,
workspaceId: (s.workspaceId as string) ?? null,
userId: (s.userId as string) ?? null,
name: s.name as string,
description: s.description as string,
content: s.content as string,
createdAt: (s.createdAt as string) ?? new Date().toISOString(),
updatedAt: s.updatedAt as string | undefined,
}))
}
/**
* Hook to fetch skills for a workspace
*/
export function useSkills(workspaceId: string) {
return useQuery<SkillDefinition[]>({
queryKey: skillsKeys.list(workspaceId),
queryFn: () => fetchSkills(workspaceId),
enabled: !!workspaceId,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
/**
* Create skill mutation
*/
interface CreateSkillParams {
workspaceId: string
skill: {
name: string
description: string
content: string
}
}
export function useCreateSkill() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, skill: s }: CreateSkillParams) => {
logger.info(`Creating skill: ${s.name} in workspace ${workspaceId}`)
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skills: [{ name: s.name, description: s.description, content: s.content }],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create skill')
}
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid API response: missing skills data')
}
logger.info(`Created skill: ${s.name}`)
return data.data
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
},
})
}
/**
* Update skill mutation
*/
interface UpdateSkillParams {
workspaceId: string
skillId: string
updates: {
name?: string
description?: string
content?: string
}
}
export function useUpdateSkill() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, skillId, updates }: UpdateSkillParams) => {
logger.info(`Updating skill: ${skillId} in workspace ${workspaceId}`)
const currentSkills = queryClient.getQueryData<SkillDefinition[]>(
skillsKeys.list(workspaceId)
)
const currentSkill = currentSkills?.find((s) => s.id === skillId)
if (!currentSkill) {
throw new Error('Skill not found')
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skills: [
{
id: skillId,
name: updates.name ?? currentSkill.name,
description: updates.description ?? currentSkill.description,
content: updates.content ?? currentSkill.content,
},
],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update skill')
}
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid API response: missing skills data')
}
logger.info(`Updated skill: ${skillId}`)
return data.data
},
onMutate: async ({ workspaceId, skillId, updates }) => {
await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) })
const previousSkills = queryClient.getQueryData<SkillDefinition[]>(
skillsKeys.list(workspaceId)
)
if (previousSkills) {
queryClient.setQueryData<SkillDefinition[]>(
skillsKeys.list(workspaceId),
previousSkills.map((s) =>
s.id === skillId
? {
...s,
name: updates.name ?? s.name,
description: updates.description ?? s.description,
content: updates.content ?? s.content,
}
: s
)
)
}
return { previousSkills }
},
onError: (_err, variables, context) => {
if (context?.previousSkills) {
queryClient.setQueryData(skillsKeys.list(variables.workspaceId), context.previousSkills)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
},
})
}
/**
* Delete skill mutation
*/
interface DeleteSkillParams {
workspaceId: string
skillId: string
}
export function useDeleteSkill() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, skillId }: DeleteSkillParams) => {
logger.info(`Deleting skill: ${skillId}`)
const response = await fetch(`${API_ENDPOINT}?id=${skillId}&workspaceId=${workspaceId}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete skill')
}
logger.info(`Deleted skill: ${skillId}`)
return data
},
onMutate: async ({ workspaceId, skillId }) => {
await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) })
const previousSkills = queryClient.getQueryData<SkillDefinition[]>(
skillsKeys.list(workspaceId)
)
if (previousSkills) {
queryClient.setQueryData<SkillDefinition[]>(
skillsKeys.list(workspaceId),
previousSkills.filter((s) => s.id !== skillId)
)
}
return { previousSkills }
},
onError: (_err, variables, context) => {
if (context?.previousSkills) {
queryClient.setQueryData(skillsKeys.list(variables.workspaceId), context.previousSkills)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
},
})
}

View File

@@ -10,6 +10,7 @@ export interface PermissionGroupConfig {
hideFilesTab: boolean
disableMcpTools: boolean
disableCustomTools: boolean
disableSkills: boolean
hideTemplates: boolean
disableInvitations: boolean
// Deploy Modal Tabs
@@ -31,6 +32,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
hideFilesTab: false,
disableMcpTools: false,
disableCustomTools: false,
disableSkills: false,
hideTemplates: false,
disableInvitations: false,
hideDeployApi: false,
@@ -59,6 +61,7 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
hideFilesTab: typeof c.hideFilesTab === 'boolean' ? c.hideFilesTab : false,
disableMcpTools: typeof c.disableMcpTools === 'boolean' ? c.disableMcpTools : false,
disableCustomTools: typeof c.disableCustomTools === 'boolean' ? c.disableCustomTools : false,
disableSkills: typeof c.disableSkills === 'boolean' ? c.disableSkills : false,
hideTemplates: typeof c.hideTemplates === 'boolean' ? c.hideTemplates : false,
disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false,
hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false,

View File

@@ -0,0 +1,86 @@
import { db } from '@sim/db'
import { skill } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('SkillsOperations')
/**
* Internal function to create/update skills.
* Can be called from API routes or internal services.
*/
export async function upsertSkills(params: {
skills: Array<{
id?: string
name: string
description: string
content: string
}>
workspaceId: string
userId: string
requestId?: string
}) {
const { skills, workspaceId, userId, requestId = generateRequestId() } = params
return await db.transaction(async (tx) => {
for (const s of skills) {
const nowTime = new Date()
if (s.id) {
const existingSkill = await tx
.select()
.from(skill)
.where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId)))
.limit(1)
if (existingSkill.length > 0) {
await tx
.update(skill)
.set({
name: s.name,
description: s.description,
content: s.content,
updatedAt: nowTime,
})
.where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId)))
logger.info(`[${requestId}] Updated skill ${s.id}`)
continue
}
}
const duplicateName = await tx
.select()
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, s.name)))
.limit(1)
if (duplicateName.length > 0) {
throw new Error(`A skill with the name "${s.name}" already exists in this workspace`)
}
await tx.insert(skill).values({
id: nanoid(),
workspaceId,
userId,
name: s.name,
description: s.description,
content: s.content,
createdAt: nowTime,
updatedAt: nowTime,
})
logger.info(`[${requestId}] Created skill "${s.name}"`)
}
const resultSkills = await tx
.select()
.from(skill)
.where(eq(skill.workspaceId, workspaceId))
.orderBy(desc(skill.createdAt))
return resultSkills
})
}

View File

@@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { parseMcpToolId } from '@/lib/mcp/utils'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
import type { ExecutionContext } from '@/executor/types'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
@@ -218,6 +219,31 @@ export async function executeTool(
// Normalize tool ID to strip resource suffixes (e.g., workflow_executor_<uuid> -> workflow_executor)
const normalizedToolId = normalizeToolId(toolId)
// Handle load_skill tool for agent skills progressive disclosure
if (normalizedToolId === 'load_skill') {
const skillName = params.skill_name
const workspaceId = params._context?.workspaceId
if (!skillName || !workspaceId) {
return {
success: false,
output: { error: 'Missing skill_name or workspace context' },
error: 'Missing skill_name or workspace context',
}
}
const content = await resolveSkillContent(skillName, workspaceId)
if (!content) {
return {
success: false,
output: { error: `Skill "${skillName}" not found` },
error: `Skill "${skillName}" not found`,
}
}
return {
success: true,
output: { content },
}
}
// If it's a custom tool, use the async version with workflowId
if (isCustomTool(normalizedToolId)) {
const workflowId = params._context?.workflowId

View File

@@ -0,0 +1,15 @@
CREATE TABLE "skill" (
"id" text PRIMARY KEY NOT NULL,
"workspace_id" text,
"user_id" text,
"name" text NOT NULL,
"description" text NOT NULL,
"content" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "skill" ADD CONSTRAINT "skill_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "skill" ADD CONSTRAINT "skill_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "skill_workspace_id_idx" ON "skill" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "skill_workspace_name_unique" ON "skill" USING btree ("workspace_id","name");

File diff suppressed because it is too large Load Diff

View File

@@ -1058,6 +1058,13 @@
"when": 1770239332381,
"tag": "0151_stale_screwball",
"breakpoints": true
},
{
"idx": 152,
"version": "7",
"when": 1770336289511,
"tag": "0152_parallel_frog_thor",
"breakpoints": true
}
]
}

View File

@@ -743,6 +743,27 @@ export const customTools = pgTable(
})
)
export const skill = pgTable(
'skill',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description').notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('skill_workspace_id_idx').on(table.workspaceId),
workspaceNameUnique: uniqueIndex('skill_workspace_name_unique').on(
table.workspaceId,
table.name
),
})
)
export const subscription = pgTable(
'subscription',
{