This document summarizes the implementation of SKILL.md definition files and skill handlers for the AIr-Friends project.
We have implemented a complete Agent Skills system that allows the external OpenCode CLI ACP Agent to interact with our chatbot through standardized SKILL.md files.
┌─────────────────────────────────────────────────────────────────┐
│ 外部 ACP Agent │
│ (OpenCode CLI) │
│ │
│ 1. 讀取 skills/{name}/SKILL.md │
│ 2. 解析 SKILL.md YAML frontmatter │
│ 3. 根據需要呼叫 skill │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Our Chatbot (ACP Client) │
│ │
│ SKILL.md 定義檔 (skills/{name}/SKILL.md) │
│ ├── memory-save/SKILL.md │
│ ├── memory-search/SKILL.md │
│ ├── memory-patch/SKILL.md │
│ ├── memory-stats/SKILL.md │
│ ├── memory-export/SKILL.md │
│ ├── send-reply/SKILL.md │
│ ├── edit-reply/SKILL.md │
│ ├── get-message/SKILL.md │
│ ├── send-file/SKILL.md │
│ ├── fetch-context/SKILL.md │
│ ├── react-message/SKILL.md │
│ ├── set-reminder/SKILL.md │
│ ├── cancel-reminder/SKILL.md │
│ └── list-reminders/SKILL.md │
│ │ │
│ Skill 處理程式 (src/skills/) │
│ ├── types.ts - Type definitions │
│ ├── memory-handler.ts - Memory operations │
│ ├── reply-handler.ts - Send/edit reply, get message │
│ ├── reaction-handler.ts - Emoji reactions │
│ ├── context-handler.ts - Fetch platform context │
│ ├── reminder-handler.ts - Reminder operations │
│ ├── file-handler.ts - Send file from workspace │
│ ├── registry.ts - Skill registration and execution │
│ └── index.ts - Public exports │
└─────────────────────────────────────────────────────────────────┘
Located in skills/{name}/SKILL.md, these files follow the Agent Skills Standard format:
- Purpose: Save important information to persistent memory
- Parameters:
content-file(required): Path of the payload file containing the memory content (staged in$TMPDIR/$SESSION_ID/)visibility: "public" or "private" (default: "public")importance: "high" or "normal" (default: "normal")
- Key Features:
- Append-only (cannot be deleted)
- Private memories only in DM contexts
- High importance memories always loaded into context
- Purpose: Search through saved memories
- Parameters:
query-file(required): Path of the payload file containing the search keywords (staged in$TMPDIR/$SESSION_ID/)limit: Maximum results (default: 10)
- Returns: Array of matching memories
- Purpose: Send final reply to user
- Parameters:
message-file(required): Path of the payload file containing the final message (staged in$TMPDIR/$SESSION_ID/)
- Critical Rule: Can only be called ONCE per interaction. After sending, use
edit-replyto modify. - Reply Threading: When triggered from a note (Misskey) or message, the reply is threaded to the original note/message using
replyToMessageIdfrom the SkillContext. For new conversations without a triggering message, a new note/message is created instead. - Content Processing: Reply content is processed through
stripXmlTags()(removes XML-like tags agents may emit) andunescapeNewlines()(converts literal\nsequences to actual newlines). - Doom-Loop Detection: The Skill API Server tracks reply attempts per session. After
MAX_REPLIES_PER_SESSION(1), subsequent send-reply calls return HTTP 429. AfterMAX_REPLY_ATTEMPTS_BEFORE_TERMINATE(4) total attempts, the agent process is terminated to prevent infinite retry loops. Similarly,edit-replyhasMAX_EDIT_CALLS_BEFORE_TERMINATE(3) — the 3rd edit attempt triggers agent termination.
- Purpose: Fetch additional context from platform
- Parameters:
type(required): "recent_messages", "search_messages", or "user_info"query-file: Path of the payload file containing the search query (for search_messages; staged in$TMPDIR/$SESSION_ID/)limit: Maximum items (default: 20)
- Use Cases: Get more history, search conversations, get user info
- Purpose: Modify memory metadata (not content)
- Parameters:
memory_id(required): ID of memory to modifyenabled: Enable/disable memoryvisibility: Change visibility levelimportance: Change importance levelrelatedTo: Comma-separated IDs of semantically related memoriessupersedes: Comma-separated IDs of memories this entry supersedes (maintenance lineage)
- Limitations: Cannot modify content, only disable
- Purpose: Get memory statistics for the current workspace
- Parameters: None
- Returns: Statistics object with public/private/summary counts (total, enabled, disabled, high-importance, normal-importance)
- Privacy: Private statistics only included in DM contexts
- Purpose: Set a one-time reminder to be delivered via DM at a future time
- Parameters:
scheduledAt(required): ISO 8601 UTC timestamp for when the reminder should firemessage-file(required): Path of the payload file containing the reminder text (staged in$TMPDIR/$SESSION_ID/)
- Constraints: DM-only, one per session, minimum 1 minute in the future, max 20 active per user
- Returns:
reminderIdandscheduledAton success
- Purpose: Cancel a previously set reminder by its ID
- Parameters:
reminderId(required): The ID of the reminder to cancel (returned by set-reminder)
- Constraints: DM-only, can only cancel own reminders
- Purpose: List all active (pending) reminders for the current user
- Parameters: None
- Constraints: DM-only
- Returns: Array of active reminders with id, message, scheduledAt, createdAt
- Purpose: Edit the last reply message sent via send-reply
- Parameters:
messageId(required): The ID of the message to edit (obtained from send-reply result)message-file(required): Path of the payload file containing the new message content (staged in$TMPDIR/$SESSION_ID/)
- Key Features:
- Can be called multiple times within a session (up to 2 edits before termination)
- Only edits messages sent by the bot in the current session
- Misskey: Uses delete-and-recreate strategy; returned
messageIdwill differ from original
- Purpose: Add an emoji reaction to the trigger message
- Parameters:
emoji(required): Emoji character (Unicode) or custom emoji name (:name:format)
- Key Features:
- Can be used with or without send-reply
- Only one reaction per session (replaces previous)
- Requires a trigger message to react to (
replyToMessageId)
- Purpose: Export all memories for the current user as a file sent via DM
- Parameters:
format: Output format —markdown(default) orjsonimportance: Filter by importance —high,normal, orall(default:all)enabled-only: Only include enabled memories —true(default) orfalse
- Key Features:
- Always delivered via DM, even if requested in a public channel
- Requires explicit user consent before execution
- Purpose: Send one or more files from the workspace to the user on the platform in a single invocation
- Parameters:
file-paths(required, repeatable): File paths relative to the workspace root; one occurrence per file, at least one required. The removed singular--file-pathflag is rejected withSKILL_SINGLE_FILE_FLAGcaption-file: Path of the payload file containing the optional caption text (staged in$TMPDIR/$SESSION_ID/)
- Key Features:
- Workspace boundary enforced (no path traversal) per file; preflight validation is all-or-nothing (one invalid path rejects the whole call with nothing sent)
- Per-file size limit (default 25 MB), batch limits
maxFilesPerInvocation(default 10) andmaxTotalSizeMb(default 50) enforced before reading file bytes - Optional extension whitelist
- Caption goes through the same
stripXmlTags→unescapeNewlinescontent pipeline assend-reply - Delivery: Discord = one message with all attachments; Misskey note = one note with all
fileIds; Misskey chat = one message per file (caption on the first), with partial-delivery reporting and best-effort Drive cleanup of unreferenced uploads on mid-batch failure - Limited to 1 successful call per session (
MAX_FILE_SENDS_PER_SESSION = 1) with doom-loop termination at 4 attempts; does NOT consume the reply quota and does NOT setreplySent/lastSentMessageId— a successful send marksfileSent, counts as a session response (suppresses the missing-response retry), and records its last delivered message ID inlastFileMessageId(neverlastSentMessageId; on Misskey chat partial delivery the last delivered ID). The session's reply anchor then resolves tolastFileMessageId ?? triggerMessageId: a subsequentsend-replythreads to the file message. A per-reply anchorlastReplyAnchorMessageId(recorded onsend-replysuccess) keepsedit-replyon the edited reply's original thread parent - Can be disabled by administrator via config
- Purpose: Get the content of a sent message by its ID
- Parameters:
messageId: The ID of the message to fetch. If omitted, falls back to the session's lastsend-reply/edit-replymessage, then to the lastsend-file-delivered message.
- Returns: Message content, userId, username, timestamp, and isBot flag
Skill scripts NEVER accept free-text content as CLI argument values. Free-text arguments (--message, --content, --query, --caption) were removed because skill scripts are executed via the Bash tool: the shell expands $VAR in double-quoted arguments before the script runs, corrupting content ($0.435 became /usr/bin/bash.435 in production) and leaking subprocess environment variables (e.g. $OPENROUTER_API_KEY) into external channels. Since agent-config/opencode.json grants skill-invocation bash patterns "allow", the script itself is the authoritative enforcement point.
The contract:
- The agent writes the free text to a payload file under the session staging directory using its edit/write tool — the literal
$TMPDIR/$SESSION_ID/...path is expanded by the ACP path boundary (src/acp/client.ts), so the write is approved and bytes are preserved verbatim. - The agent invokes the script with the payload-file flag:
--message-file(send-reply/edit-reply/set-reminder),--content-file(memory-save),--query-file(memory-search/fetch-context),--caption-file(send-file). - The shared helper (
skills/lib/payload.ts) resolves the payload path against the script's cwd (the session workspace) and requires it to be inside{workspace}/tmp/{sessionId}(boundary-safe, symlink-aware viaDeno.realPath), so a payload file can never exfiltrate arbitrary workspace/home files. The payload file is deleted after a successful read.
Legacy flags are rejected in both forms (--flag value and --flag=value) with a typed error (SKILL_LEGACY_FLAG / SKILL_MISSING_PAYLOAD / SKILL_PAYLOAD_OUT_OF_BOUNDS / SKILL_PAYLOAD_NOT_FOUND / SKILL_SINGLE_FILE_FLAG for the removed singular --file-path) whose error field teaches the correct two-step pattern with a concrete example invocation. The ACP gate additionally rejects skill commands carrying a legacy free-text flag as defense-in-depth.
SkillCall: Structure of skill invocationSkillResult: Return value from skill executionSkillContext: Context passed to skill handlers, includes:workspace: Workspace informationplatformAdapter: Platform interface for sending messageschannelId: Target channel IDuserId: User who triggered the interactionreplyToMessageId: Optional resolved reply anchor —lastFileMessageId ?? triggerMessageId— for reply threadingtriggerMessageId: Original message ID that triggered the session (target ofreact-message)lastSentMessageId: Last message ID sent viasend-reply/edit-replyONLY (edit-reply scoping, get-message fallback)lastFileMessageId: Last message ID delivered bysend-file(reply anchor, get-message fallback)lastReplyAnchorMessageId: Reply anchor recorded when the last text reply was created (edit-reply thread-parent preservation)
- Parameter types for each skill
Handles all memory-related operations:
handleMemorySave: Validates parameters and saves memory using MemoryStorehandleMemorySearch: Searches memories by keywordshandleMemoryPatch: Patches memory metadatahandleMemoryStats: Returns memory statisticshandleMemoryExport: Exports memories as file via DM
Key Features:
- Parameter validation for all inputs
- Security check: private memories only in DM contexts
- Proper error handling and logging
Manages reply sending with strict once-per-interaction enforcement:
handleSendReply: Sends reply via platform adapterhandleEditReply: Edits previously sent reply messagehandleGetMessage: Retrieves message content by ID- Session tracking to prevent multiple replies
clearReplyState: Clears state for new interactions- Content processing:
stripXmlTags()removes XML-like tags,unescapeNewlines()converts literal\nto newlines
Critical Feature: Maintains state map to ensure only one reply per session
Fetches additional context from platform:
handleFetchContext: Routes to appropriate context fetch method- Supports:
- Recent messages (via
fetchRecentMessages) - Message search (via
searchRelatedMessages) - User info (via
getUsername)
- Recent messages (via
Manages emoji reactions on trigger messages:
handleReactMessage: Adds emoji reaction via platform adapter- Session tracking to prevent duplicate reactions
clearReactionState: Clears state for new interactions- Requires
triggerMessageId(the original trigger message — never a bot-sent message such as a file message)
Handles reminder CRUD operations (conditionally registered when reminders are enabled):
handleSetReminder: Creates a one-time reminder (DM-only, one per session)handleCancelReminder: Cancels a reminder by ID (ownership verified)handleListReminders: Lists active pending remindersclearSessionState: Clears per-session tracking
Handles file sending from workspace (conditionally registered when send-file is enabled):
handleSendFile: Validates all paths, reads files, and sends via platform adapter (multi-file)- Path security validation (no traversal, workspace boundary enforced) per file
- Per-file size limit, batch count/aggregate-size limits (preflight before any byte is read), and extension whitelist checks
- Caption content pipeline (
stripXmlTags→unescapeNewlines) fileSentresponse-state map withhasFileSent/clearFileState(a successful send — including partial Misskey chat delivery — marks the session as responded)- Result contract:
messageIds,messageId,filesCount,nextAction; partial failures carry the delivered IDs alongside the error
Central registry for all skills:
executeSkill: Executes skill by namegetAvailableSkills: Lists all registered skillshasSkill: Checks if skill existsgetReplyHandler: Access to reply handler for state managementgetReactionHandler: Access to reaction handler for state managementgetReminderHandler: Access to reminder handler for session state managementgetFileHandler: Access to file handler for response state management (null when send-file is disabled)
Comprehensive test suite in tests/skills/:
- Tests memory save with valid/invalid parameters
- Tests memory search functionality
- Tests memory patching
- Tests successful reply sending
- Tests once-per-interaction enforcement
- Tests parameter validation
- Tests state clearing
- Tests skill registration
- Tests skill execution
- Tests unknown skill handling
- Tests access to reply handler
All tests pass successfully!
When an ACP Agent completes a prompt turn (stopReason === "end_turn") without calling send-reply or react-message, the system automatically retries:
- Clears the reply state (but not reaction state) to allow a new reply
- Sends a retry prompt on the same ACP session requesting the agent to send a reply or reaction
- Retry strategy is configured per agent type via
getRetryPromptStrategy()insrc/acp/agent-factory.ts(all agents: max 1 retry) - If the retry also fails to produce a reply or reaction, the system returns a failure response
The skill system is integrated through the Skill API Server (src/skill-api/server.ts) which handles HTTP requests from shell-based skill scripts. The SkillRegistry is initialized with MemoryStore and optional ReminderStore/SendFileSkillConfig:
import { SkillRegistry } from "@skills/registry.ts";
import { MemoryStore } from "@core/memory-store.ts";
// Initialize with optional features
const skillRegistry = new SkillRegistry(
memoryStore,
remindersConfig, // optional
reminderStore, // optional
sendFileConfig, // optional
);
// Execute skill
const result = await skillRegistry.executeSkill(
"memory-save",
{ content: "User likes hiking", visibility: "public" },
context,
);- Workspace Isolation: All operations respect workspace boundaries
- Private Memory Protection: Private memories only accessible in DM contexts
- Parameter Validation: All inputs validated before processing
- Once-per-interaction: Reply sending enforced to prevent spam
- Error Handling: All errors caught and logged without crashing
A Skill API request is authenticated by both the session ID and a per-session caller token, bound to the subprocess that owns the session — a valid session ID alone is not sufficient.
- At session registration (
SessionRegistry.register) a high-entropycallerToken(256-bit, distinct from the session ID) is minted and stored on theActiveSession. - The token is provisioned into the owning agent subprocess's environment as
SKILL_API_TOKEN(allow-listed inSandboxManager.BASE_ALLOWED_ENV, set alongsideSESSION_IDinagent-factory.ts).skills/lib/client.tsreads it and sends it asAuthorization: Bearer <token>. - The server resolves the session by ID and verifies the presented token against the
stored token with a constant-time comparison (
timingSafeEqual), returning HTTP 403 on a missing/mismatched token. Authentication runs before the request-dedup cache, and 401/403 outcomes are never cached, so an unauthorized attempt holding a leaked session ID cannot poison a legitimate caller's cached result. - Idle TTL: sessions expire after
skillApi.sessionTimeoutMs(default 30 min) of inactivity.SessionRegistry.get()treats an idle-expired session as absent (401); each authenticated calltouch()es the session so an actively-used session does not expire mid-turn; a cleanup timer reaps idle entries.
Honest scope note: the caller token is injected into the agent environment, so it
shares the exact exposure of SESSION_ID under a /proc/<pid>/environ read. It does
not by itself defend against an attacker who can already read the victim
subprocess's environment directly — that vector is closed by the agent filesystem
confinement (F12), not by this token. The token's value is against session-ID leakage
through other channels (logs, dashboard, error messages) and the removal of ambient
"any holder of the ID" bearer authority. The two changes compose.
Documented future work (D3): move the Skill API from a shared TCP port to a per-session channel (a unix domain socket bound to one session) so the server derives the session from the connection rather than a client-supplied field, making caller identity OS-enforced rather than token-asserted.
Skill permissions are enforced through a whitelist mechanism managed by SkillAutoApproveList in src/acp/client.ts. In restricted (non-YOLO) mode, only whitelisted skill commands are auto-approved; all other execution requests are rejected.
The auto-approve list uses two matching methods:
- Script path matching (
scriptPaths): For skills with ascripts/directory, path suffixes likeskills/memory-save/scripts/memory-save.tsare stored. A command is approved if any whitespace-delimited token exactly equals or ends with a stored path suffix. - Command prefix matching (
commandPrefixes): For command-based skills without ascripts/directory (e.g.,agent-browser), the first whitespace-delimited token is checked for an exact match against stored prefixes.
The list can be configured explicitly via agent.autoApproveSkills in config.yaml or the AGENT_AUTO_APPROVE_SKILLS environment variable (comma-separated). When neither is configured, the system falls back to scanning the built-in skills/ directory automatically.
For detailed documentation on permission layers and shell injection protection, see Skill Auto-Approve List in AGENT_PERMISSIONS.md.
- Memory Compression: Automatic memory summarization for large contexts
- Advanced Search: Semantic search in memories using embeddings
- Skill Analytics: Track skill usage and performance metrics
This implementation provides a complete Agent Skills system that follows the Agent Skills Standard, integrates seamlessly with the existing codebase, and includes comprehensive tests. External ACP Agents can now read the SKILL.md files from the workspace and call our skill handlers to perform operations like memory management, context fetching, and reply sending.