π File detail
tools/SkillTool/SkillTool.ts
π― Use case
This module implements the βSkillToolβ tool (Skill) β something the model can call at runtime alongside other agent tools. On the API surface it exposes inputSchema, outputSchema, Output, and SkillTool β mainly functions, hooks, or classes. Dependencies touch bun:bundle, @anthropic-ai, lodash-es, and Node path helpers. It composes internal code from bootstrap, constants, hooks, services, and utils (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { feature } from 'bun:bundle' import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' import uniqBy from 'lodash-es/uniqBy.js' import { dirname } from 'path' import { getProjectRoot } from 'src/bootstrap/state.js'
π€ Exports (heuristic)
inputSchemaoutputSchemaOutputSkillTool
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundle@anthropic-ailodash-espathsrczod
π₯οΈ Source preview
import { feature } from 'bun:bundle'
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import uniqBy from 'lodash-es/uniqBy.js'
import { dirname } from 'path'
import { getProjectRoot } from 'src/bootstrap/state.js'
import {
builtInCommandNames,
findCommand,
getCommands,
type PromptCommand,
} from 'src/commands.js'
import type {
Tool,
ToolCallProgress,
ToolResult,
ToolUseContext,
ValidationResult,
} from 'src/Tool.js'
import { buildTool, type ToolDef } from 'src/Tool.js'
import type { Command } from 'src/types/command.js'
import type {
AssistantMessage,
AttachmentMessage,
Message,
SystemMessage,
UserMessage,
} from 'src/types/message.js'
import { logForDebugging } from 'src/utils/debug.js'
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js'
import {
isOfficialMarketplaceName,
parsePluginIdentifier,
} from 'src/utils/plugins/pluginIdentifier.js'
import { buildPluginCommandTelemetryFields } from 'src/utils/telemetry/pluginTelemetry.js'
import { z } from 'zod/v4'
import {
addInvokedSkill,
clearInvokedSkillsForAgent,
getSessionId,
} from '../../bootstrap/state.js'
import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
logEvent,
} from '../../services/analytics/index.js'
import { getAgentContext } from '../../utils/agentContext.js'
import { errorMessage } from '../../utils/errors.js'
import {
extractResultText,
prepareForkedCommandContext,
} from '../../utils/forkedAgent.js'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { createUserMessage, normalizeMessages } from '../../utils/messages.js'
import type { ModelAlias } from '../../utils/model/aliases.js'
import { resolveSkillModelOverride } from '../../utils/model/model.js'
import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js'
import { createAgentId } from '../../utils/uuid.js'
import { runAgent } from '../AgentTool/runAgent.js'
import {
getToolUseIDFromParentMessage,
tagMessagesWithToolUseID,
} from '../utils.js'
import { SKILL_TOOL_NAME } from './constants.js'
import { getPrompt } from './prompt.js'
import {
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
renderToolUseProgressMessage,
renderToolUseRejectedMessage,
} from './UI.js'
/**
* Gets all commands including MCP skills/prompts from AppState.
* SkillTool needs this because getCommands() only returns local/bundled skills.
*/
async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
// Only include MCP skills (loadedFrom === 'mcp'), not plain MCP prompts.
// Before this filter, the model could invoke MCP prompts via SkillTool
// if it guessed the mcp__server__prompt name β they weren't discoverable
// but were technically reachable.
const mcpSkills = context
.getAppState()
.mcp.commands.filter(
cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
)
if (mcpSkills.length === 0) return getCommands(getProjectRoot())
const localCommands = await getCommands(getProjectRoot())
return uniqBy([...localCommands, ...mcpSkills], 'name')
}
// Re-export Progress from centralized types to break import cycles
export type { SkillToolProgress as Progress } from '../../types/tools.js'
import type { SkillToolProgress as Progress } from '../../types/tools.js'
// Conditional require for remote skill modules β static imports here would
// pull in akiBackend.ts (via remoteSkillLoader β akiBackend), which has
// module-level memoize()/lazySchema() consts that survive tree-shaking as
// side-effecting initializers. All usages are inside
// feature('EXPERIMENTAL_SKILL_SEARCH') guards, so remoteSkillModules is
// non-null at every call site.
/* eslint-disable @typescript-eslint/no-require-imports */
const remoteSkillModules = feature('EXPERIMENTAL_SKILL_SEARCH')
? {
...(require('../../services/skillSearch/remoteSkillState.js') as typeof import('../../services/skillSearch/remoteSkillState.js')),
...(require('../../services/skillSearch/remoteSkillLoader.js') as typeof import('../../services/skillSearch/remoteSkillLoader.js')),
...(require('../../services/skillSearch/telemetry.js') as typeof import('../../services/skillSearch/telemetry.js')),
...(require('../../services/skillSearch/featureCheck.js') as typeof import('../../services/skillSearch/featureCheck.js')),
}
: null
/* eslint-enable @typescript-eslint/no-require-imports */
/**
* Executes a skill in a forked sub-agent context.
* This runs the skill prompt in an isolated agent with its own token budget.
*/
async function executeForkedSkill(
command: Command & { type: 'prompt' },
commandName: string,
args: string | undefined,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<Progress>,
): Promise<ToolResult<Output>> {
const startTime = Date.now()
const agentId = createAgentId()
const isBuiltIn = builtInCommandNames().has(commandName)
const isOfficialSkill = isOfficialMarketplaceSkill(command)
const isBundled = command.source === 'bundled'
const forkedSanitizedName =
isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom'
const wasDiscoveredField =
feature('EXPERIMENTAL_SKILL_SEARCH') &&
remoteSkillModules!.isSkillSearchEnabled()
? {
was_discovered:
context.discoveredSkillNames?.has(commandName) ?? false,
}
: {}
const pluginMarketplace = command.pluginInfo
? parsePluginIdentifier(command.pluginInfo.repository).marketplace
: undefined
const queryDepth = context.queryTracking?.depth ?? 0
const parentAgentId = getAgentContext()?.agentId
logEvent('tengu_skill_tool_invocation', {
command_name:
forkedSanitizedName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
// _PROTO_skill_name routes to the privileged skill_name BQ column
// (unredacted, all users); command_name stays in additional_metadata as
// the redacted variant for general-access dashboards.
_PROTO_skill_name:
commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
execution_context:
'fork' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
invocation_trigger: (queryDepth > 0
? 'nested-skill'
: 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
query_depth: queryDepth,
...(parentAgentId && {
parent_agent_id:
parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
...wasDiscoveredField,
...(process.env.USER_TYPE === 'ant' && {
skill_name:
commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
skill_source:
command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(command.loadedFrom && {
skill_loaded_from:
command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
...(command.kind && {
skill_kind:
command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
}),
...(command.pluginInfo && {
// _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns
// (unredacted, all users); plugin_name/plugin_repository stay in
// additional_metadata as redacted variants.
_PROTO_plugin_name: command.pluginInfo.pluginManifest
.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
...(pluginMarketplace && {
_PROTO_marketplace_name:
pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
}),
plugin_name: (isOfficialSkill
? command.pluginInfo.pluginManifest.name
: 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
plugin_repository: (isOfficialSkill
? command.pluginInfo.repository
: 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...buildPluginCommandTelemetryFields(command.pluginInfo),
}),
})
const { modifiedGetAppState, baseAgent, promptMessages, skillContent } =
await prepareForkedCommandContext(command, args || '', context)
// Merge skill's effort into the agent definition so runAgent applies it
const agentDefinition =
command.effort !== undefined
? { ...baseAgent, effort: command.effort }
: baseAgent
// Collect messages from the forked agent
const agentMessages: Message[] = []
logForDebugging(
`SkillTool executing forked skill ${commandName} with agent ${agentDefinition.agentType}`,
)
try {
// Run the sub-agent
for await (const message of runAgent({
agentDefinition,
promptMessages,
toolUseContext: {
...context,
getAppState: modifiedGetAppState,
},
canUseTool,
isAsync: false,
querySource: 'agent:custom',
model: command.model as ModelAlias | undefined,
availableTools: context.options.tools,
override: { agentId },
})) {
agentMessages.push(message)
// Report progress for tool uses (like AgentTool does)
if (
(message.type === 'assistant' || message.type === 'user') &&
onProgress
) {
const normalizedNew = normalizeMessages([message])
for (const m of normalizedNew) {
const hasToolContent = m.message.content.some(
c => c.type === 'tool_use' || c.type === 'tool_result',
)
if (hasToolContent) {
onProgress({
toolUseID: `skill_${parentMessage.message.id}`,
data: {
message: m,
type: 'skill_progress',
prompt: skillContent,
agentId,
},
})
}
}
}
}
const resultText = extractResultText(
agentMessages,
'Skill execution completed',
)
// Release message memory after extracting result
agentMessages.length = 0
const durationMs = Date.now() - startTime
logForDebugging(
`SkillTool forked skill ${commandName} completed in ${durationMs}ms`,
)
return {
data: {
success: true,
commandName,
status: 'forked',
agentId,
result: resultText,
},
}
} finally {
// Release skill content from invokedSkills state
clearInvokedSkillsForAgent(agentId)
}
}
export const inputSchema = lazySchema(() =>
z.object({
skill: z
.string()
.describe('The skill name. E.g., "commit", "review-pr", or "pdf"'),
args: z.string().optional().describe('Optional arguments for the skill'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
export const outputSchema = lazySchema(() => {
// Output schema for inline skills (default)
const inlineOutputSchema = z.object({
success: z.boolean().describe('Whether the skill is valid'),
commandName: z.string().describe('The name of the skill'),
allowedTools: z
.array(z.string())
.optional()
.describe('Tools allowed by this skill'),
model: z.string().optional().describe('Model override if specified'),
status: z.literal('inline').optional().describe('Execution status'),
})
// Output schema for forked skills
const forkedOutputSchema = z.object({
success: z.boolean().describe('Whether the skill completed successfully'),
commandName: z.string().describe('The name of the skill'),
status: z.literal('forked').describe('Execution status'),
agentId: z
.string()
.describe('The ID of the sub-agent that executed the skill'),
result: z.string().describe('The result from the forked skill execution'),
})
return z.union([inlineOutputSchema, forkedOutputSchema])
})
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.input<OutputSchema>
export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
name: SKILL_TOOL_NAME,
searchHint: 'invoke a slash-command skill',
maxResultSizeChars: 100_000,
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
description: async ({ skill }) => `Execute skill: ${skill}`,
prompt: async () => getPrompt(getProjectRoot()),
// Only one skill/command should run at a time, since the tool expands the
// command into a full prompt that Claude must process before continuing.
// Skill-coach needs the skill name to avoid false-positive "you could have
// used skill X" suggestions when X was actually invoked. Backseat classifies
// downstream tool calls from the expanded prompt, not this wrapper, so the
// name alone is sufficient β it just records that the skill fired.
toAutoClassifierInput: ({ skill }) => skill ?? '',
async validateInput({ skill }, context): Promise<ValidationResult> {
// Skills are just skill names, no arguments
const trimmed = skill.trim()
if (!trimmed) {
return {
result: false,
message: `Invalid skill format: ${skill}`,
errorCode: 1,
}
}
// Remove leading slash if present (for compatibility)
const hasLeadingSlash = trimmed.startsWith('/')
if (hasLeadingSlash) {
logEvent('tengu_skill_tool_slash_prefix', {})
}
const normalizedCommandName = hasLeadingSlash
? trimmed.substring(1)
: trimmed
// Remote canonical skill handling (ant-only experimental). Intercept
// `_canonical_<slug>` names before local command lookup since remote
// skills are not in the local command registry.
if (
feature('EXPERIMENTAL_SKILL_SEARCH') &&
process.env.USER_TYPE === 'ant'
) {
const slug = remoteSkillModules!.stripCanonicalPrefix(
normalizedCommandName,
)
if (slug !== null) {
const meta = remoteSkillModules!.getDiscoveredRemoteSkill(slug)
if (!meta) {
return {
result: false,
message: `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`,
errorCode: 6,
}
}
// Discovered remote skill β valid. Loading happens in call().
return { result: true }
}
}
// Get available commands (including MCP skills)
const commands = await getAllCommands(context)
// Check if command exists
const foundCommand = findCommand(normalizedCommandName, commands)
if (!foundCommand) {
return {
result: false,
message: `Unknown skill: ${normalizedCommandName}`,
errorCode: 2,
}
}
// Check if command has model invocation disabled
if (foundCommand.disableModelInvocation) {
return {
result: false,
message: `Skill ${normalizedCommandName} cannot be used with ${SKILL_TOOL_NAME} tool due to disable-model-invocation`,
errorCode: 4,
}
}
// Check if command is a prompt-based command
if (foundCommand.type !== 'prompt') {
return {
result: false,
message: `Skill ${normalizedCommandName} is not a prompt-based skill`,
errorCode: 5,
}
}
return { result: true }
},
async checkPermissions(
{ skill, args },
context,
): Promise<PermissionDecision> {
// Skills are just skill names, no arguments
const trimmed = skill.trim()
// Remove leading slash if present (for compatibility)
const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed
const appState = context.getAppState()
const permissionContext = appState.toolPermissionContext
// Look up the command object to pass as metadata
const commands = await getAllCommands(context)
const commandObj = findCommand(commandName, commands)
// Helper function to check if a rule matches the skill
// Normalizes both inputs by stripping leading slashes for consistent matching
const ruleMatches = (ruleContent: string): boolean => {
// Normalize rule content by stripping leading slash
const normalizedRule = ruleContent.startsWith('/')
? ruleContent.substring(1)
: ruleContent
// Check exact match (using normalized commandName)
if (normalizedRule === commandName) {
return true
}
// Check prefix match (e.g., "review:*" matches "review-pr 123")
if (normalizedRule.endsWith(':*')) {
const prefix = normalizedRule.slice(0, -2) // Remove ':*'
return commandName.startsWith(prefix)
}
return false
}
// Check for deny rules
const denyRules = getRuleByContentsForTool(
permissionContext,
SkillTool as Tool,
'deny',
)
for (const [ruleContent, rule] of denyRules.entries()) {
if (ruleMatches(ruleContent)) {
return {
behavior: 'deny',
message: `Skill execution blocked by permission rules`,
decisionReason: {
type: 'rule',
rule,
},
}
}
}
// Remote canonical skills are ant-only experimental β auto-grant.
// Placed AFTER the deny loop so a user-configured Skill(_canonical_:*)
// deny rule is honored (same pattern as safe-properties auto-allow below).
// The skill content itself is canonical/curated, not user-authored.
if (
feature('EXPERIMENTAL_SKILL_SEARCH') &&
process.env.USER_TYPE === 'ant'
) {
const slug = remoteSkillModules!.stripCanonicalPrefix(commandName)
if (slug !== null) {
return {
behavior: 'allow',
updatedInput: { skill, args },
decisionReason: undefined,
}
}
}
// Check for allow rules
const allowRules = getRuleByContentsForTool(
permissionContext,
SkillTool as Tool,
'allow',
)
for (const [ruleContent, rule] of allowRules.entries()) {
if (ruleMatches(ruleContent)) {
return {
behavior: 'allow',
updatedInput: { skill, args },
decisionReason: {
type: 'rule',
rule,
},
}
}
}
// Auto-allow skills that only use safe properties.
// This is an allowlist: if a skill has any property NOT in this set with a
// meaningful value, it requires permission. This ensures new properties added
// in the future default to requiring permission.
if (
commandObj?.type === 'prompt' &&
skillHasOnlySafeProperties(commandObj)
) {
return {
behavior: 'allow',
updatedInput: { skill, args },
decisionReason: undefined,
}
}
// Prepare suggestions for exact skill and prefix
// Use normalized commandName (without leading slash) for consistent rules
const suggestions = [
// Exact skill suggestion
{
type: 'addRules' as const,
rules: [
{
toolName: SKILL_TOOL_NAME,
ruleContent: commandName,
},
],
behavior: 'allow' as const,
destination: 'localSettings' as const,
},
// Prefix suggestion to allow any args
{
type: 'addRules' as const,
rules: [
{
toolName: SKILL_TOOL_NAME,
ruleContent: `${commandName}:*`,
},
],
behavior: 'allow' as const,
destination: 'localSettings' as const,
},
]
// Default behavior: ask user for permission
return {
behavior: 'ask',
message: `Execute skill: ${commandName}`,
decisionReason: undefined,
suggestions,
updatedInput: { skill, args },
metadata: commandObj ? { command: commandObj } : undefined,
}
},
async call(
{ skill, args },
context,
canUseTool,
parentMessage,
onProgress?,
): Promise<ToolResult<Output>> {
// At this point, validateInput has already confirmed:
// - Skill format is valid
// - Skill exists
// - Skill can be loaded
// - Skill doesn't have disableModelInvocation
// - Skill is a prompt-based skill
// Skills are just names, with optional arguments
const trimmed = skill.trim()
// Remove leading slash if present (for compatibility)
const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed
// Remote canonical skill execution (ant-only experimental). Intercepts
// `_canonical_<slug>` before local command lookup β loads SKILL.md from
// AKI/GCS (with local cache), injects content directly as a user message.
// Remote skills are declarative markdown so no slash-command expansion
// (no !command substitution, no $ARGUMENTS interpolation) is needed.
if (
feature('EXPERIMENTAL_SKILL_SEARCH') &&
process.env.USER_TYPE === 'ant'
) {
const slug = remoteSkillModules!.stripCanonicalPrefix(commandName)
if (slug !== null) {
return executeRemoteSkill(slug, commandName, parentMessage, context)
}
}
const commands = await getAllCommands(context)
const command = findCommand(commandName, commands)
// Track skill usage for ranking
recordSkillUsage(commandName)
// Check if skill should run as a forked sub-agent
if (command?.type === 'prompt' && command.context === 'fork') {
return executeForkedSkill(
command,
commandName,
args,
context,
canUseTool,
parentMessage,
onProgress,
)
}
// Process the skill with optional args
const { processPromptSlashCommand } = await import(
'src/utils/processUserInput/processSlashCommand.js'
)
const processedCommand = await processPromptSlashCommand(
commandName,
args || '', // Pass args if provided
commands,
context,
)
if (!processedCommand.shouldQuery) {
throw new Error('Command processing failed')
}
// Extract metadata from the command
const allowedTools = processedCommand.allowedTools || []
const model = processedCommand.model
const effort = command?.type === 'prompt' ? command.effort : undefined
const isBuiltIn = builtInCommandNames().has(commandName)
const isBundled = command?.type === 'prompt' && command.source === 'bundled'
const isOfficialSkill =
command?.type === 'prompt' && isOfficialMarketplaceSkill(command)
const sanitizedCommandName =
isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom'
const wasDiscoveredField =
feature('EXPERIMENTAL_SKILL_SEARCH') &&
remoteSkillModules!.isSkillSearchEnabled()
? {
was_discovered:
context.discoveredSkillNames?.has(commandName) ?? false,
}
: {}
const pluginMarketplace =
command?.type === 'prompt' && command.pluginInfo
? parsePluginIdentifier(command.pluginInfo.repository).marketplace
: undefined
const queryDepth = context.queryTracking?.depth ?? 0
const parentAgentId = getAgentContext()?.agentId
logEvent('tengu_skill_tool_invocation', {
command_name:
sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
// _PROTO_skill_name routes to the privileged skill_name BQ column
// (unredacted, all users); command_name stays in additional_metadata as
// the redacted variant for general-access dashboards.
_PROTO_skill_name:
commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
execution_context:
'inline' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
invocation_trigger: (queryDepth > 0
? 'nested-skill'
: 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
query_depth: queryDepth,
...(parentAgentId && {
parent_agent_id:
parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
...wasDiscoveredField,
...(process.env.USER_TYPE === 'ant' && {
skill_name:
commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(command?.type === 'prompt' && {
skill_source:
command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
...(command?.loadedFrom && {
skill_loaded_from:
command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
...(command?.kind && {
skill_kind:
command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
}),
...(command?.type === 'prompt' &&
command.pluginInfo && {
_PROTO_plugin_name: command.pluginInfo.pluginManifest
.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
...(pluginMarketplace && {
_PROTO_marketplace_name:
pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
}),
plugin_name: (isOfficialSkill
? command.pluginInfo.pluginManifest.name
: 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
plugin_repository: (isOfficialSkill
? command.pluginInfo.repository
: 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...buildPluginCommandTelemetryFields(command.pluginInfo),
}),
})
// Get the tool use ID from the parent message for linking newMessages
const toolUseID = getToolUseIDFromParentMessage(
parentMessage,
SKILL_TOOL_NAME,
)
// Tag user messages with sourceToolUseID so they stay transient until this tool resolves
const newMessages = tagMessagesWithToolUseID(
processedCommand.messages.filter(
(m): m is UserMessage | AttachmentMessage | SystemMessage => {
if (m.type === 'progress') {
return false
}
// Filter out command-message since SkillTool handles display
if (m.type === 'user' && 'message' in m) {
const content = m.message.content
if (
typeof content === 'string' &&
content.includes(`<${COMMAND_MESSAGE_TAG}>`)
) {
return false
}
}
return true
},
),
toolUseID,
)
logForDebugging(
`SkillTool returning ${newMessages.length} newMessages for skill ${commandName}`,
)
// Note: addInvokedSkill and registerSkillHooks are called inside
// processPromptSlashCommand (via getMessagesForPromptSlashCommand), so
// calling them again here would double-register hooks and rebuild
// skillContent redundantly.
// Return success with newMessages and contextModifier
return {
data: {
success: true,
commandName,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
model,
},
newMessages,
contextModifier(ctx) {
let modifiedContext = ctx
// Update allowed tools if specified
if (allowedTools.length > 0) {
// Capture the current getAppState to chain modifications properly
const previousGetAppState = modifiedContext.getAppState
modifiedContext = {
...modifiedContext,
getAppState() {
// Use the previous getAppState, not the closure's context.getAppState,
// to properly chain context modifications
const appState = previousGetAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: [
...new Set([
...(appState.toolPermissionContext.alwaysAllowRules
.command || []),
...allowedTools,
]),
],
},
},
}
},
}
}
// Carry [1m] suffix over β otherwise a skill with `model: opus` on an
// opus[1m] session drops the effective window to 200K and trips autocompact.
if (model) {
modifiedContext = {
...modifiedContext,
options: {
...modifiedContext.options,
mainLoopModel: resolveSkillModelOverride(
model,
ctx.options.mainLoopModel,
),
},
}
}
// Override effort level if skill specifies one
if (effort !== undefined) {
const previousGetAppState = modifiedContext.getAppState
modifiedContext = {
...modifiedContext,
getAppState() {
const appState = previousGetAppState()
return {
...appState,
effortValue: effort,
}
},
}
}
return modifiedContext
},
}
},
mapToolResultToToolResultBlockParam(
result: Output,
toolUseID: string,
): ToolResultBlockParam {
// Handle forked skill result
if ('status' in result && result.status === 'forked') {
return {
type: 'tool_result' as const,
tool_use_id: toolUseID,
content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`,
}
}
// Inline skill result (default)
return {
type: 'tool_result' as const,
tool_use_id: toolUseID,
content: `Launching skill: ${result.commandName}`,
}
},
renderToolResultMessage,
renderToolUseMessage,
renderToolUseProgressMessage,
renderToolUseRejectedMessage,
renderToolUseErrorMessage,
} satisfies ToolDef<InputSchema, Output, Progress>)
// Allowlist of PromptCommand property keys that are safe and don't require permission.
// If a skill has any property NOT in this set with a meaningful value, it requires
// permission. This ensures new properties added to PromptCommand in the future
// default to requiring permission until explicitly reviewed and added here.
const SAFE_SKILL_PROPERTIES = new Set([
// PromptCommand properties
'type',
'progressMessage',
'contentLength',
'argNames',
'model',
'effort',
'source',
'pluginInfo',
'disableNonInteractive',
'skillRoot',
'context',
'agent',
'getPromptForCommand',
'frontmatterKeys',
// CommandBase properties
'name',
'description',
'hasUserSpecifiedDescription',
'isEnabled',
'isHidden',
'aliases',
'isMcp',
'argumentHint',
'whenToUse',
'paths',
'version',
'disableModelInvocation',
'userInvocable',
'loadedFrom',
'immediate',
'userFacingName',
])
function skillHasOnlySafeProperties(command: Command): boolean {
for (const key of Object.keys(command)) {
if (SAFE_SKILL_PROPERTIES.has(key)) {
continue
}
// Property not in safe allowlist - check if it has a meaningful value
const value = (command as Record<string, unknown>)[key]
if (value === undefined || value === null) {
continue
}
if (Array.isArray(value) && value.length === 0) {
continue
}
if (
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
continue
}
return false
}
return true
}
function isOfficialMarketplaceSkill(command: PromptCommand): boolean {
if (command.source !== 'plugin' || !command.pluginInfo?.repository) {
return false
}
return isOfficialMarketplaceName(
parsePluginIdentifier(command.pluginInfo.repository).marketplace,
)
}
/**
* Extract URL scheme for telemetry. Defaults to 'gs' for unrecognized schemes
* since the AKI backend is the only production path and the loader throws on
* unknown schemes before we reach telemetry anyway.
*/
function extractUrlScheme(url: string): 'gs' | 'http' | 'https' | 's3' {
if (url.startsWith('gs://')) return 'gs'
if (url.startsWith('https://')) return 'https'
if (url.startsWith('http://')) return 'http'
if (url.startsWith('s3://')) return 's3'
return 'gs'
}
/**
* Load a remote canonical skill and inject its SKILL.md content into the
* conversation. Unlike local skills (which go through processPromptSlashCommand
* for !command / $ARGUMENTS expansion), remote skills are declarative markdown
* β we wrap the content directly in a user message.
*
* The skill is also registered with addInvokedSkill so it survives compaction
* (same as local skills).
*
* Only called from within a feature('EXPERIMENTAL_SKILL_SEARCH') guard in
* call() β remoteSkillModules is non-null here.
*/
async function executeRemoteSkill(
slug: string,
commandName: string,
parentMessage: AssistantMessage,
context: ToolUseContext,
): Promise<ToolResult<Output>> {
const { getDiscoveredRemoteSkill, loadRemoteSkill, logRemoteSkillLoaded } =
remoteSkillModules!
// validateInput already confirmed this slug is in session state, but we
// re-fetch here to get the URL. If it's somehow gone (e.g., state cleared
// mid-session), fail with a clear error rather than crashing.
const meta = getDiscoveredRemoteSkill(slug)
if (!meta) {
throw new Error(
`Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`,
)
}
const urlScheme = extractUrlScheme(meta.url)
let loadResult
try {
loadResult = await loadRemoteSkill(slug, meta.url)
} catch (e) {
const msg = errorMessage(e)
logRemoteSkillLoaded({
slug,
cacheHit: false,
latencyMs: 0,
urlScheme,
error: msg,
})
throw new Error(`Failed to load remote skill ${slug}: ${msg}`)
}
const {
cacheHit,
latencyMs,
skillPath,
content,
fileCount,
totalBytes,
fetchMethod,
} = loadResult
logRemoteSkillLoaded({
slug,
cacheHit,
latencyMs,
urlScheme,
fileCount,
totalBytes,
fetchMethod,
})
// Remote skills are always model-discovered (never in static skill_listing),
// so was_discovered is always true. is_remote lets BQ queries separate
// remote from local invocations without joining on skill name prefixes.
const queryDepth = context.queryTracking?.depth ?? 0
const parentAgentId = getAgentContext()?.agentId
logEvent('tengu_skill_tool_invocation', {
command_name:
'remote_skill' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
// _PROTO_skill_name routes to the privileged skill_name BQ column
// (unredacted, all users); command_name stays in additional_metadata as
// the redacted variant.
_PROTO_skill_name:
commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
execution_context:
'remote' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
invocation_trigger: (queryDepth > 0
? 'nested-skill'
: 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
query_depth: queryDepth,
...(parentAgentId && {
parent_agent_id:
parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
was_discovered: true,
is_remote: true,
remote_cache_hit: cacheHit,
remote_load_latency_ms: latencyMs,
...(process.env.USER_TYPE === 'ant' && {
skill_name:
commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
remote_slug:
slug as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}),
})
recordSkillUsage(commandName)
logForDebugging(
`SkillTool loaded remote skill ${slug} (cacheHit=${cacheHit}, ${latencyMs}ms, ${content.length} chars)`,
)
// Strip YAML frontmatter (---\nname: x\n---) before prepending the header
// (matches loadSkillsDir.ts:333). parseFrontmatter returns the original
// content unchanged if no frontmatter is present.
const { content: bodyContent } = parseFrontmatter(content, skillPath)
// Inject base directory header + ${CLAUDE_SKILL_DIR}/${CLAUDE_SESSION_ID}
// substitution (matches loadSkillsDir.ts) so the model can resolve relative
// refs like ./schemas/foo.json against the cache dir.
const skillDir = dirname(skillPath)
const normalizedDir =
process.platform === 'win32' ? skillDir.replace(/\\/g, '/') : skillDir
let finalContent = `Base directory for this skill: ${normalizedDir}\n\n${bodyContent}`
finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, normalizedDir)
finalContent = finalContent.replace(
/\$\{CLAUDE_SESSION_ID\}/g,
getSessionId(),
)
// Register with compaction-preservation state. Use the cached file path so
// post-compact restoration knows where the content came from. Must use
// finalContent (not raw content) so the base directory header and
// ${CLAUDE_SKILL_DIR} substitutions survive compaction β matches how local
// skills store their already-transformed content via processSlashCommand.
addInvokedSkill(
commandName,
skillPath,
finalContent,
getAgentContext()?.agentId ?? null,
)
// Direct injection β wrap SKILL.md content in a meta user message. Matches
// the shape of what processPromptSlashCommand produces for simple skills.
const toolUseID = getToolUseIDFromParentMessage(
parentMessage,
SKILL_TOOL_NAME,
)
return {
data: { success: true, commandName, status: 'inline' },
newMessages: tagMessagesWithToolUseID(
[createUserMessage({ content: finalContent, isMeta: true })],
toolUseID,
),
}
}