Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ec7058f
feat(skills): permissions layer
icecrasher321 Jul 16, 2026
df45ee4
chore(db): drop skill_member migration 0261 for regeneration on lates…
icecrasher321 Jul 16, 2026
4fd444b
Merge remote-tracking branch 'origin/staging' into staging-v2
icecrasher321 Jul 16, 2026
7817222
feat(db): regenerate skill_member migration as 0262 on latest staging
icecrasher321 Jul 16, 2026
cf1fe8f
Merge remote-tracking branch 'origin/staging' into staging-v2
icecrasher321 Jul 16, 2026
144fab1
feat(db): regenerate skill_member migration as 0263 after staging merge
icecrasher321 Jul 16, 2026
66eae4b
chore(db): drop skill_member migration 0263 for regeneration on lates…
icecrasher321 Jul 18, 2026
f59d801
Merge remote-tracking branch 'origin/staging' into staging-v2
icecrasher321 Jul 18, 2026
5c3066a
feat(db): regenerate skill_member migration as 0264 after staging merge
icecrasher321 Jul 18, 2026
d7c6b52
make editing skills full page
icecrasher321 Jul 18, 2026
8ea35db
fix disclaimer
icecrasher321 Jul 18, 2026
5eb4ce8
edit access msg
icecrasher321 Jul 18, 2026
6bd6c8f
fix lint
icecrasher321 Jul 18, 2026
0973bf2
chore(db): drop skill_member migration 0264 for regeneration on lates…
icecrasher321 Jul 22, 2026
3f6f939
Merge remote-tracking branch 'origin/staging' into staging-v2
icecrasher321 Jul 22, 2026
e13b7fc
feat(db): regenerate skill_member migration as 0265 after staging merge
icecrasher321 Jul 22, 2026
029fa18
fix tests
icecrasher321 Jul 22, 2026
0ca5549
simplify system
icecrasher321 Jul 22, 2026
ef9ad17
fix
icecrasher321 Jul 22, 2026
5ce3561
fix lint
icecrasher321 Jul 22, 2026
6172523
add mship skills docs
icecrasher321 Jul 22, 2026
dbbd02d
chore(db): drop skill_member migration 0265 for regeneration on lates…
icecrasher321 Jul 23, 2026
7e51179
Merge remote-tracking branch 'origin/staging' into staging-v2
icecrasher321 Jul 23, 2026
14d93ca
feat(db): regenerate skill_member migration as 0266 after staging merge
icecrasher321 Jul 23, 2026
4200b3a
fix(deps): override zod to 4.3.6 to dedupe nested copies breaking typ…
icecrasher321 Jul 23, 2026
ebc2d13
fix lint
icecrasher321 Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions apps/docs/content/docs/en/agents/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Skills use **progressive disclosure** to keep agent context lean:

## Creating Skills

Skills live on the **Integrations** page: click **Integrations** in the workspace sidebar, then switch to the **Skills** tab. It lists every skill in the workspace, searchable by name.
Skills live on the **Integrations** page: click **Integrations** in the workspace sidebar, then switch to the **Skills** tab. It lists every skill in the workspace, searchable by name. Click a skill to open its detail page, where you edit, share, and delete it.

![The Skills tab on the Integrations page](/static/skills/skills-tab.png)

Expand Down Expand Up @@ -77,6 +77,24 @@ Use when the user asks you to write, optimize, or debug SQL queries.

Keep skills focused and under 500 lines. If a skill grows too large, split it into multiple specialized skills.

## Skill Editors

Everyone in the workspace sees and uses every skill — including members who join later. Nobody needs to be added to a skill to use it.

Each skill has an explicit **editors** list. Editors can edit the skill, delete it, and manage the editors list. Workspace admins can always do this too — they are editors of every skill automatically and cannot be removed from the list. Whoever creates a skill becomes an editor.

Open a skill from the Skills tab to manage it. The detail page has the editable fields, a **Share** action for adding editors from your workspace members, and the **Skill Editors** list at the bottom.

<Callout type="info">
The editors list controls who can edit a skill — it never affects who can see, use, or run it. A workflow that references a skill always executes it, no matter who runs the workflow. Treat skill content as shared team instructions, not as a secret.
</Callout>

## Using Skills in Chat

Skills work in Chat too. Type `/` in the message box to open the skills menu, then pick a skill — or keep typing to filter by name. The skill appears in your message as a tag, e.g. `/format-markdown`.

Tagging a skill loads its full instructions into the conversation, so Sim follows them for that request — no waiting for Sim to decide the skill is relevant on its own.

## Adding Skills to an Agent

Open any **Agent** block and find the **Skills** dropdown below the tools section. Select the skills you want the agent to have access to.
Expand Down Expand Up @@ -156,5 +174,7 @@ import { FAQ } from '@/components/ui/faq'
{ question: "When should I use skills vs. agent instructions?", answer: "Use skills for knowledge that applies across multiple workflows or changes frequently. Skills are reusable packages that can be attached to any agent. Use agent instructions for task-specific context that is unique to a single agent and workflow. If you find yourself copying the same instructions into multiple agents, that content should be a skill instead." },
{ question: "Can permission groups disable skills for certain users?", answer: "Yes. On Enterprise-entitled workspaces, any workspace admin can create a permission group with the disableSkills option enabled. When a user is assigned to such a group in a workspace, the skills dropdown in agent blocks is disabled and they cannot add or use skills in workflows belonging to that workspace." },
{ question: "What is the recommended maximum length for skill content?", answer: "Keep skills focused and under 500 lines. If a skill grows too large, split it into multiple specialized skills. Shorter, focused skills are more effective because the agent can load exactly what it needs. A broad skill with too much content can overwhelm the agent and reduce the quality of its responses." },
{ question: "Where do I create and manage skills?", answer: "Click Integrations in the workspace sidebar and switch to the Skills tab. Add Skill creates one from a name (kebab-case, max 64 characters), description (max 1024 characters), and markdown content — or imports an existing SKILL.md from a file, a GitHub URL, or pasted content. Existing skills are edited and deleted from the same tab." },
{ question: "Where do I create and manage skills?", answer: "Click Integrations in the workspace sidebar and switch to the Skills tab. Add Skill creates one from a name (kebab-case, max 64 characters), description (max 1024 characters), and markdown content — or imports an existing SKILL.md from a file, a GitHub URL, or pasted content. Click any existing skill to open its detail page, where you edit, share, and delete it." },
{ question: "Who can edit a skill, and who can use it?", answer: "Everyone in the workspace — including people who join later — sees and uses every skill without being added to anything. Each skill has an editors list: editors and workspace admins (who are always editors, automatically) can edit, delete, and share the skill, and whoever creates a skill becomes an editor. The editors list never affects who can use or run a skill: a workflow that references a skill always executes it." },
{ question: "Can I use skills in Chat?", answer: "Yes. Type / in the Chat message box to open the skills menu and pick a skill — for example /format-markdown. The tag loads the skill's full instructions into the conversation, so Sim follows them for that request without having to decide on its own to load the skill." },
]} />
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"tailwind-merge": "^3.0.2",
"reactflow": "^11.11.4",
"framer-motion": "^12.5.0",
"zod": "^4.3.6"
"zod": "4.3.6"
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
Expand Down
61 changes: 27 additions & 34 deletions apps/sim/app/api/mothership/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
assertActiveWorkspaceAccess,
getUserEntityPermissions,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
import type { ChatContext } from '@/stores/panel'
Expand Down Expand Up @@ -130,7 +129,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
}
const userId = auth.userId ?? bodyUserId

await assertActiveWorkspaceAccess(workspaceId, userId)
const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId)
const billingAttribution = requireBillingAttributionHeader(req.headers, {
actorUserId: userId,
workspaceId,
Expand All @@ -152,38 +151,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
context.kind === 'mcp' && context.serverId ? [context.serverId] : []
)
const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp')
const [
workspaceContext,
integrationTools,
mothershipTools,
userPermission,
entitlements,
agentContexts,
] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
Promise.all([
buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []),
buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds),
]).then((groups) => {
const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
return [...byName.values()]
}),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
computeWorkspaceEntitlements(workspaceId, userId),
processContextsServer(
nonMcpAgentMentions,
userId,
lastUserMessage,
workspaceId,
effectiveChatId
).catch((error) => {
reqLogger.warn('Failed to resolve agent contexts for execution', {
error: toError(error).message,
})
return []
}),
])
const userPermission = workspaceAccess.permission
const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] =
await Promise.all([
generateWorkspaceContext(workspaceId, userId, { workspaceAccess }),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
Promise.all([
buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []),
buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds),
]).then((groups) => {
const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
return [...byName.values()]
}),
computeWorkspaceEntitlements(workspaceId, userId),
processContextsServer(
nonMcpAgentMentions,
userId,
lastUserMessage,
workspaceId,
effectiveChatId
).catch((error) => {
reqLogger.warn('Failed to resolve agent contexts for execution', {
error: toError(error).message,
})
return []
}),
])
const requestPayload: Record<string, unknown> = {
messages,
responseFormat,
Expand Down
243 changes: 243 additions & 0 deletions apps/sim/app/api/skills/[id]/members/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { skillMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { removeSkillMemberContract, upsertSkillMemberContract } from '@/lib/api/contracts/skills'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { getSkillActorContext, listSkillEditors } from '@/lib/skills/access'
import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('SkillMembersAPI')

interface RouteContext {
params: Promise<{ id: string }>
}

type SkillEditorGate =
| { ok: true; workspaceId: string }
| { ok: false; reason: 'not-found' | 'not-editor' }

/**
* Resolves the skill and asserts the actor can edit it (explicit editor row or
* derived workspace admin). Skills the actor cannot reach at all (missing,
* builtin, no workspace, no workspace access) read as not-found;
* visible-but-not-editor reads as forbidden.
*/
async function requireSkillEditor(skillId: string, userId: string): Promise<SkillEditorGate> {
if (isBuiltinSkillId(skillId)) return { ok: false, reason: 'not-found' }

const actor = await getSkillActorContext(skillId, userId)
if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) {
return { ok: false, reason: 'not-found' }
}
if (!actor.canEdit) return { ok: false, reason: 'not-editor' }

return { ok: true, workspaceId: actor.skill.workspaceId }
}

function skillEditorGateResponse(reason: 'not-found' | 'not-editor'): NextResponse {
return reason === 'not-found'
? NextResponse.json({ error: 'Not found' }, { status: 404 })
: NextResponse.json({ error: 'Skill editor access required' }, { status: 403 })
}

export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id: skillId } = await context.params

if (isBuiltinSkillId(skillId)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}

const actor = await getSkillActorContext(skillId, session.user.id)
if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}

const editors = await listSkillEditors({
id: actor.skill.id,
workspaceId: actor.skill.workspaceId,
})

return NextResponse.json({ editors })
} catch (error) {
logger.error('Failed to fetch skill editors', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})

export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id: skillId } = await context.params

const gate = await requireSkillEditor(skillId, session.user.id)
if (!gate.ok) {
logger.warn('Skill editor add denied', {
skillId,
actorId: session.user.id,
reason: gate.reason,
})
return skillEditorGateResponse(gate.reason)
}

const parsed = await parseRequest(upsertSkillMemberContract, request, context)
if (!parsed.success) return parsed.response

const { userId } = parsed.data.body

const targetWorkspacePerm = await getUserEntityPermissions(
userId,
'workspace',
gate.workspaceId
)
if (targetWorkspacePerm === null) {
return NextResponse.json({ error: 'User is not a member of this workspace' }, { status: 400 })
}
if (targetWorkspacePerm === 'admin') {
return NextResponse.json(
{ error: 'Workspace admins can always edit skills' },
{ status: 400 }
)
}

const [existing] = await db
.select({ id: skillMember.id })
.from(skillMember)
.where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId)))
.limit(1)

if (existing) {
return NextResponse.json({ success: true })
}

const now = new Date()
// Conflict-safe against a concurrent add racing the unique (skillId, userId) index.
await db
.insert(skillMember)
.values({
id: generateId(),
skillId,
userId,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] })

captureServerEvent(
session.user.id,
'skill_shared',
{ skill_id: skillId, workspace_id: gate.workspaceId },
{ groups: { workspace: gate.workspaceId } }
)

recordAudit({
workspaceId: gate.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.SKILL_MEMBER_ADDED,
resourceType: AuditResourceType.SKILL,
resourceId: skillId,
description: 'Added skill editor',
metadata: { targetUserId: userId },
request,
})

return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add skill editor', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})

export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id: skillId } = await context.params

const gate = await requireSkillEditor(skillId, session.user.id)
if (!gate.ok) {
logger.warn('Skill editor removal denied', {
skillId,
actorId: session.user.id,
reason: gate.reason,
})
return skillEditorGateResponse(gate.reason)
}

const parsed = await parseRequest(removeSkillMemberContract, request, context)
if (!parsed.success) return parsed.response

const { userId: targetUserId } = parsed.data.query

const targetWorkspacePerm = await getUserEntityPermissions(
targetUserId,
'workspace',
gate.workspaceId
)
if (targetWorkspacePerm === 'admin') {
return NextResponse.json(
{ error: 'Workspace admins can always edit skills' },
{ status: 400 }
)
}

// Hard delete — no deny markers and no last-editor guard: workspace admins
// always remain derived editors, so a skill can never be orphaned.
const removed = await db
.delete(skillMember)
.where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, targetUserId)))
.returning({ id: skillMember.id })

if (removed.length === 0) {
return NextResponse.json({ error: 'Editor not found' }, { status: 404 })
}

captureServerEvent(
session.user.id,
'skill_unshared',
{ skill_id: skillId, workspace_id: gate.workspaceId },
{ groups: { workspace: gate.workspaceId } }
)

recordAudit({
workspaceId: gate.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.SKILL_MEMBER_REMOVED,
resourceType: AuditResourceType.SKILL,
resourceId: skillId,
description: 'Removed skill editor',
metadata: { targetUserId },
request,
})

return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove skill editor', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
Loading
Loading