This document provides comprehensive guidance for AI agents working on the AIr-Friends project. It covers architecture, coding standards, build processes, and key design decisions.
AIr-Friends is a multi-platform conversational AI bot that acts as an ACP (Agent Client Protocol) Client, delegating AI reasoning to the external OpenCode CLI agent while maintaining persistent cross-conversation memory.
Key Concepts:
- We are the ACP Client: We spawn and communicate with external ACP Agents
- External CLI tool is the Agent: OpenCode CLI executes AI tasks
- Skills are shell-based: We provide Deno TypeScript skill scripts that Agents can execute
- Skill API Server: HTTP server for skills to communicate back to the main bot
- Workspace isolation: Each conversation context has its own isolated working directory
| Component | Technology | Version |
|---|---|---|
| Runtime | Deno | 2.x |
| Language | TypeScript | (Deno native) |
| ACP SDK | @agentclientprotocol/sdk | 0.14.1 |
| Discord Library | discord.js | ^14.0.0 |
| Misskey Library | misskey-js | 2025.12.2 |
| Configuration | YAML (via @std/yaml) | - |
| Testing | Deno.test + @std/assert | - |
┌─────────────────────────────────────────────────────────────┐
│ AIr-Friends (ACP CLIENT) │
├─────────────────────────────────────────────────────────────┤
│ Platform Adapters (Discord/Misskey) │
│ ↓ │
│ AgentCore → SessionOrchestrator │
│ ↓ │
│ AgentConnector → ACP ClientSideConnection │
│ ↓ (spawn subprocess, stdio JSON-RPC) │
├─────────────────────────────────────────────────────────────┤
│ External ACP AGENTS │
│ (OpenCode CLI) │
│ ↓ (executes our shell-based skills) │
├─────────────────────────────────────────────────────────────┤
│ Shell Skills (Deno scripts in skills/ directory) │
│ ↓ (calls back via HTTP) │
│ Skill API Server (HTTP endpoint) │
│ ↓ │
│ Skill Handlers (memory, reply, context) │
│ Memory Store, Workspace Manager │
└─────────────────────────────────────────────────────────────┘
| Directory | Purpose |
|---|---|
src/core/ |
Agent session, workspace manager, context assembly |
src/core/reply-policy.ts |
Access control and reply policy evaluation |
src/acp/ |
ACP Client integration, agent connector |
src/platforms/ |
Platform adapters (Discord, Misskey) |
src/skills/ |
Internal skill handlers (memory, reply, context) |
src/skill-api/ |
HTTP server for shell-based skills |
src/types/ |
TypeScript type definitions |
src/utils/ |
Logging, configuration loading, utilities |
skills/ |
Shell-based skill scripts (executed by external agent) |
Always run these commands from the project root:
# Development (with hot reload)
deno task dev
# Production
deno task start
# Run all tests
deno task test
# Format code (REQUIRED before commit)
deno fmt src/ tests/
# Lint code (REQUIRED before commit)
deno lint src/ tests/
# Type check
deno check src/main.ts
# Format check only (CI uses this)
deno fmt --check src/ tests/When running manually, use these explicit permissions:
deno run --allow-net --allow-read --allow-write --allow-env --allow-run src/main.tsNever use --allow-all. Required permissions:
| Permission | Purpose |
|---|---|
--allow-net |
Discord API, Misskey API, external connections |
--allow-read |
Configuration files, workspace files, memory logs |
--allow-write |
Memory log files in workspace directories |
--allow-env |
Environment variables (tokens, configuration) |
--allow-run |
Spawning ACP agent subprocesses and skill scripts |
The --yolo flag enables automatic approval of ALL permission requests from the ACP agent:
deno run --allow-net --allow-read --allow-write --allow-env src/main.ts --yoloYOLO mode can also be configured per-channel via the yolo field in channels configuration:
channels:
- id: "discord/account/560842157351763989"
enabled: true
yolo: true # This account runs Agent in YOLO modeEffective YOLO logic: effectiveYolo = globalYoloFlag || channelConfig.yolo
Use cases:
- Testing and development (global
--yoloflag) - Trusted channels/accounts (per-channel
yolo: true)
Account-Level YOLO (Account-Level YOLO):
- When
channelscontains a{platform}/account/{id}entry withyolo: true, that account triggers YOLO mode regardless of whether it sends from a DM, any channel, or any other trigger method - This differs from channel-level YOLO (
{platform}/channel/{id}withyolo: true), which only activates within a specific channel - The YOLO flag propagates through the full chain to
ChatbotClient.requestPermission(), affecting all permission types (tool call, external directory, edit/write, etc.)
Warning: Only use YOLO mode in isolated/trusted environments. It bypasses all permission checks for agent actions.
The --dry-run flag enables dry run / debug mode. The system assembles context but does NOT call the ACP Agent — instead, the assembled prompt is written to an output directory:
deno run --allow-net --allow-read --allow-write --allow-env --allow-run src/main.ts --dry-runUse cases:
- Prompt engineering and context debugging
- CI/CD smoke tests (zero API cost)
- Verifying memory / context assembly output
Configuration (in config.yaml under agent:):
agent:
dryRun:
enabled: false # Enable dry run mode (default: false)
outputPath: "./data/dry-run/" # Output directory for assembled prompts
mockReply: "(Dry run 模式 — 此為測試回覆)" # Mock reply text (empty = no reply)Environment Variable Overrides:
DRY_RUN_ENABLED→agent.dryRun.enabledDRY_RUN_OUTPUT_PATH→agent.dryRun.outputPathDRY_RUN_MOCK_REPLY→agent.dryRun.mockReply
Behavior: Steps 1-6 of the session flow (workspace, session registration, context assembly, prompt rendering) execute normally. The agent connector is never created. If mockReply is non-empty and a platform adapter is available, a test reply is sent to the platform. Output files are named {sessionType}_{timestamp}.md.
This project uses Deno's built-in formatter and linter. Configuration is in deno.json:
| Rule | Setting |
|---|---|
| Line Width | 100 |
| Indent | 2 spaces |
| Tabs | No |
| Single Quotes | No |
| Prose Wrap | preserve |
Use path aliases defined in deno.json:
// ✅ Correct - use aliases
import { Logger } from "@utils/logger.ts";
import { WorkspaceManager } from "@core/workspace.ts";
import { NormalizedEvent } from "@types/event.ts";
// ❌ Wrong - avoid relative paths
import { Logger } from "../../../utils/logger.ts";Available aliases:
| Alias | Path |
|---|---|
@core/ |
./src/core/ |
@platforms/ |
./src/platforms/ |
@skills/ |
./src/skills/ |
@types/ |
./src/types/ |
@utils/ |
./src/utils/ |
- Write comments in English
- Use JSDoc for public APIs
- Avoid obvious comments; explain "why", not "what"
workspace_key = "{platform}/{user_id}"- Workspace is per-user, not per-channel — the same user's memories are shared across all channels/threads they interact in
- Each workspace is an isolated directory under
repo/workspaces/ - Agent sessions use workspace path as current working directory (cwd)
- No cross-workspace file access allowed
- The
SESSION_IDenvironment variable is set for the agent subprocess during active sessions
// Workspace path structure
const workspacePath = `${config.workspace.repo_path}/workspaces/${platform}/${userId}`;
// Each workspace includes a tmp/ subdirectory, exposed via TMPDIR env var to Agent
const tmpPath = `${workspacePath}/tmp`;In addition to per-user workspaces, the Agent has a global workspace at {workspace.repoPath}/agent-workspace/ for storing cross-conversation knowledge, research notes, and reflections.
- Not per-user: Shared across all conversations and users
- Markdown-based: All files use
.mdformat for token efficiency - Not pre-loaded: Content is NOT included in initial context; Agent reads on-demand via
$AGENT_WORKSPACEenv var - Index-guided:
notes/_index.mdserves as a quick-reference index - Privacy boundary: User private data must NOT be stored here (use
memory-saveinstead) - Write-gated (read-only by default): The shared workspace is a cross-user store, so WRITE access is restricted to sessions explicitly authorized via the
canWriteAgentWorkspaceflag — currently only self-research sessions. Ordinary user, spontaneous, channel-lurk, and memory-maintenance sessions have read-only access; their edit/write requests to the shared workspace are rejected at the ACP permission gate (bothrequestPermissionandwriteTextFile). Per-session$TMPDIRwrites remain allowed regardless. Memory-maintenance operates on per-user memory JSONL via memory skills, not the shared workspace. - No auto-approved network egress:
curl/wgetare NOT in the OpenCode restricted-mode bash allow-list; agents fetch web content via the dedicatedwebfetch/agent-browserskills (or run under YOLO / network isolation for research).
data/agent-workspace/
├── README.md # Workspace usage guide
├── notes/ # Knowledge notes by topic
│ ├── _index.md # Notes index (agent-maintained)
│ └── {topic-slug}.md # Individual topic files
└── journal/ # Daily reflections
└── {YYYY-MM-DD}.md # Daily entries
The memory-search skill automatically searches both user memories and agent workspace notes, returning results in separate userMemories and agentNotes sections.
Initial context comprises:
| Source | Limit |
|---|---|
| High-importance memories | All enabled |
| Recent channel messages | 20 messages (fixed) |
| Guild-related context | Configurable |
No automatic memory compression or summarization during normal message handling. Optional scheduled memory maintenance can be enabled separately.
/clear Command:
When a message starts with /clear, it acts as a context reset command:
- If the trigger message itself is
/clear: The system immediately returns without executing the agent or sending any reply, as this is purely a command, not a conversation requiring a response. - If
/clearappears in recent message history: When assembling context, the system drops that message and everything before it—only messages after the last/clearare included. This lets users reset conversation context within the same channel (e.g., Discord DMs where switching channels is impractical).
The command only affects recent channel messages, not memories or guild-related context.
Memories are organized into three tiers:
| Tier | Purpose | Decay Default | Working Limit |
|---|---|---|---|
core |
Persistent identity facts | 1.0 (no decay) | N/A |
working |
Active, recent conversation context | 0.8 | Configurable (default: 20) |
archive |
Long-term storage | 0.5 | N/A |
When the working tier exceeds memory.workingTierLimit, the oldest working memories are automatically demoted to archive tier.
user(default): Per-user memories shared across all channels the user interacts inchannel: Channel-scoped memories stored alongside the channel context (e.g., group conversation topics, channel-specific decisions)
Each memory has a category for classification:
| Category | Description |
|---|---|
fact |
Objective information (default) |
preference |
User preferences and likes/dislikes |
episode |
Specific conversation events or experiences |
summary |
Conversation summaries (auto-generated or manual) |
relationship |
Relationship information between people |
After each session, the system can automatically generate a conversation summary using the system_summary.md prompt template. This is controlled by conversationSummary.enabled (default: true). Summaries are saved as working-tier memories with category summary.
Append-only JSONL files (both exist in every workspace):
memory.public.jsonl- Public memoriesmemory.private.jsonl- Private memories
Memory event structure:
interface MemoryEvent {
type: "memory";
id: string; // Unique ID
ts: string; // ISO 8601 timestamp
enabled: boolean;
visibility: "public" | "private";
importance: "high" | "normal";
tier: "core" | "working" | "archive"; // Memory tier (default: "archive")
category: "fact" | "preference" | "episode" | "summary" | "relationship"; // Classification (default: "fact")
scope: "user" | "channel"; // Memory scope (default: "user")
decay: number; // 0.0–1.0 temporal relevance (default varies by tier)
content: string; // Plain text
relatedTo?: string[]; // IDs of semantically related memories
supersedes?: string[]; // IDs of memories this entry supersedes
}Memory cannot be deleted, only disabled via patch events:
interface PatchEvent {
type: "patch";
target_id: string;
ts: string;
changes: {
enabled?: boolean;
visibility?: "public" | "private";
importance?: "high" | "normal";
tier?: "core" | "working" | "archive";
category?: "fact" | "preference" | "episode" | "summary" | "relationship";
decay?: number; // 0.0–1.0 (ignored for core tier)
relatedTo?: string[]; // IDs of semantically related memories
supersedes?: string[]; // IDs of memories this patch's target supersedes
};
}memory:
searchLimit: 10
maxChars: 2000
recentMessageLimit: 20
workingTierLimit: 20 # Max working-tier memories before demotion to archive
conversationSummary:
enabled: true # Auto-generate conversation summaries (default: true)
model: "" # Model for summaries (defaults to agent.model)Environment Variable Overrides:
MEMORY_WORKING_TIER_LIMIT→memory.workingTierLimitCONVERSATION_SUMMARY_ENABLED→conversationSummary.enabledCONVERSATION_SUMMARY_MODEL→conversationSummary.model
Shell-Based Skills Architecture:
- Skills are Deno TypeScript scripts in
skills/{skill-name}/scripts/directories - Each skill has a
SKILL.mdfile describing its usage for the agent - External Agents execute these scripts with
--session-idparameter - Scripts use shared client library in
skills/lib/client.ts - Scripts call back to main bot via HTTP API (Skill API Server on localhost:3001)
- Session-based authentication ensures security
- The
SESSION_IDenvironment variable is set for the agent subprocess with the active session ID - Payload-file argument contract: free-text content (reply text, memory content, search queries, captions, reminder text) MUST NEVER appear on a skill command line — the shell expands
$VARin it, corrupting content and leaking subprocess env vars into external channels. The agent writes the text to$TMPDIR/$SESSION_ID/{name}.mdwith its edit/write tool, then passes the path via the payload-file flag (--message-file,--content-file,--query-file,--caption-file). The shared helperskills/lib/payload.tsenforces session-scoped containment ({workspace}/tmp/{sessionId}, symlink-aware) and raises instructive typed errors (SKILL_LEGACY_FLAG,SKILL_MISSING_PAYLOAD,SKILL_PAYLOAD_OUT_OF_BOUNDS,SKILL_PAYLOAD_NOT_FOUND,SKILL_SINGLE_FILE_FLAG) that teach the correct pattern.
Available Skills:
| Skill | Purpose | HTTP Endpoint |
|---|---|---|
memory-save |
Save new memory | POST /api/skill/memory-save |
memory-search |
Search existing memories | POST /api/skill/memory-search |
memory-patch |
Update memory attributes | POST /api/skill/memory-patch |
memory-stats |
Get memory statistics | POST /api/skill/memory-stats |
fetch-context |
Get additional platform data | POST /api/skill/fetch-context |
send-reply |
Send final reply (max 1) | POST /api/skill/send-reply |
edit-reply |
Edit last sent reply | POST /api/skill/edit-reply |
send-file |
Send 1+ workspace files (max 1 call/session) | POST /api/skill/send-file |
Reply Rule:
- Only
send-replyandsend-fileskills send content externally (react-messagesends a reaction) - Multiple replies are allowed per session — each call sends a separate message
- At least one reply, one reaction, or one file send per session is required; if none occurred, the retry mechanism triggers
send-fileis limited to 1 successful call per session (MAX_FILE_SENDS_PER_SESSION = 1; a multi-file batch counts as one call) with doom-loop protection at 4 attempts — it does NOT consume the reply quota, does NOT setreplySent, and does NOT updatelastSentMessageId(file messages are notedit-reply-able); on delivery it records the last delivered message ID inlastFileMessageId(neverlastSentMessageId), and the session's reply anchor resolves tolastFileMessageId ?? triggerMessageId— a subsequentsend-replythreads to the file message, while a per-reply anchor (lastReplyAnchorMessageId, recorded onsend-replysuccess) keepsedit-replyon the edited reply's original thread parentsend-fileaccepts a repeatable--file-pathsflag (one occurrence per file, at least one required); the removed singular--file-pathis rejected withSKILL_SINGLE_FILE_FLAG; captions follow the payload-file flow (--caption-file) and go through the samestripXmlTags→unescapeNewlinescontent pipeline as replies- 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 — a successful file send marksfileSentand suppresses the missing-response retry - Batch limits:
skills.sendFile.maxFilesPerInvocation(default 10) andskills.sendFile.maxTotalSizeMb(default 50) are enforced before reading file bytes; preflight validation is all-or-nothing (one invalid path rejects the whole call with nothing sent) - A file-only turn does NOT trigger conversation summary generation (the summary gate stays
replySent) - All other outputs (tool calls, reasoning) stay internal
- Reply Threading: When triggered from a message/note, replies are threaded to the resolved reply anchor (
lastFileMessageId ?? triggerMessageId) from SkillContext — the original trigger message untilsend-filedelivers files, the file message afterwards.react-messagealways targets the original trigger (triggerMessageId), never the bot's own messages;edit-replypreserves the edited reply's original thread parent vialastReplyAnchorMessageId
Edit Reply:
edit-replyskill allows the Agent to edit a previously sent reply- Requires the
messageIdreturned bysend-reply - Can be called multiple times within the same session
- Only edits messages sent by the bot
Platform-Specific Reply Behavior:
- Misskey: When triggered from a note, the reply is sent as a reply to that note (using
replyId). For scheduled/time-triggered messages without a source note, a new note is created instead. - Discord: Replies are sent to the same channel (threading not yet implemented).
Skill API Implementation:
// Skill scripts call HTTP API with session ID and parameters
const result = await fetch("http://localhost:3001/api/skill/memory-save", {
method: "POST",
body: JSON.stringify({
sessionId: "sess_abc123",
parameters: { content: "User likes TypeScript", visibility: "public" },
}),
});Normalized event model:
interface NormalizedEvent {
platform: string; // "discord" | "misskey"
channel_id: string;
user_id: string;
message_id: string;
is_dm: boolean;
guild_id?: string;
content: string;
timestamp: string;
}Platform adapters must implement:
fetchRecentMessages(channelId, limit)searchMessages(channelId, query)sendReply(channelId, content, options?)
Misskey-Specific Notes:
- Username Format: When building context, usernames are formatted as
@DisplayName (userId)for better identification in conversation history - Note Channel ID: Notes use
note:{noteId}as channel ID for reply threading - DM Channel ID: DMs use
dm:{userId}as channel ID - Chat Channel ID: Private chat messages use
chat:{userId}as channel ID, supporting Misskey's chat feature for 1-on-1 messaging - Bot Filtering:
shouldRespondToNote()andshouldRespondToChatMessage()checkuser.isBot/fromUser?.isBotto ignore messages from bot accounts, preventing multi-instance infinite loops. Bot messages in recent history are correctly marked as[Bot]viaisBotinnoteToPlatformMessage()andchatMessageToPlatformMessage(). - Note Edit Strategy: Misskey API has no
notes/updateendpoint.editMessage()uses a delete-and-recreate strategy (notes/delete→notes/create). The new note'sreplyIdpoints to the edited reply's recorded thread parent (lastReplyAnchorMessageId— the message it was created as a reply to: the file message when the reply followed a file send, otherwise the trigger note), never the current anchor, so an edit never rewrites thread topology. The returnedmessageIdwill be different from the original.
Misskey Channel Types:
| Channel ID Format | Description | API Endpoint |
|---|---|---|
note:{noteId} |
Public note conversation thread | notes/replies, notes/create |
dm:{userId} |
Direct message via specified notes | notes/mentions |
chat:{userId} |
Private chat room with specific user | chat/messages/user-timeline, chat/messages/create-to-user |
We use @agentclientprotocol/sdk for Client-side connection:
AgentConnector (src/acp/agent-connector.ts):
- Spawns external ACP agent as subprocess (opencode CLI)
- Creates bidirectional JSON-RPC stream (stdin/stdout)
- Manages agent lifecycle (connect, disconnect, cleanup)
ChatbotClient (src/acp/client.ts):
- Implements ACP
Clientinterface - Handles callbacks from external agents:
requestPermission: Permission requests (auto-approves registered skills, or all requests in YOLO mode)sessionUpdate: Session state changesreadTextFile: Read files from workspacewriteTextFile: Write files to workspace
Permission Handling:
- Restricted mode: Auto-approves registered skills, skills directory access, and skill auto-approve list matched commands (script paths + command prefixes). The auto-approve list can be configured via
agent.autoApproveSkillsin config orAGENT_AUTO_APPROVE_SKILLSenv var (comma-separated). When not configured, falls back to scanning the built-inskills/directory. Edit/write requests are classified by the ACP toolkind("edit"— what OpenCode v1.17.13+ sends for itswrite/edit/apply_patch/patchtools, whosetitleis the target file path) OR by legacy title values ("edit","edit_file","write","write_file"); requests matching neither fall through toRejecting unknown tool call. - Edit/Write Path Extraction: When
locationsis empty in an edit/write permission request, the system attempts to extract file paths fromrawInput(checking fields:path,file_path,filePath,filepath,file,filename,paths,files— covering both the OpenCode write shape{filePath, content}and edit shape{filepath, diff}). Extracted paths go through the standard workspace boundary and extension checks. If paths cannot be extracted from eitherlocationsorrawInput, the request is rejected (conservative approach to maintain security boundaries). - Permission rejection feedback (retry prompt):
ChatbotClientrecords every permission denial (requestPermission()+writeTextFile()) in a bounded per-session ring buffer (max 10 entries,commandOrPathtruncated to 200 chars). The missing-reply retry prompt includes the recent rejection reasons (toolName,kind, rejected command/path, reason) as a bounded diagnostic section so the Agent can self-correct. The buffer is NOT cleared byreset()(which runs at the start of every prompt, including the retry); it is cleared exactly once per logical session inAgentConnector.createSession(). - Generic-command gate (F12 D2): allow-listed read/media tools (
rg,cat,head,tail,ls,find,wc,file,tree,jq,pdftotext,pdfinfo,pdfimages,pdftoppm) are approved only when every path argument — input and output — resolves inside the session workspace/TMPDIR, the agent workspace, or the session's OpenCode tool-output dir. The tool-output boundary is session-local: OpenCode runs withXDG_DATA_HOME={workspace}/tmp/opencode-data/{sessionId}, so truncated tool outputs land inside the session workspace; the shared home-rooted~/.local/share/opencode/tool-outputis never within bounds (fail closed), and paths inside the data area that belong to sibling/previous sessions (or the enumerating root listing) are rejected. Home-anchored tokens (~,~/,$HOME,${HOME},$XDG_DATA_HOME,${XDG_DATA_HOME}, including attached option values like-o$HOME/...) are expanded and containment-checked; unexpandable forms (~otheruser/...) are rejected, as are attached short-option traversal values (-f../sibling/file,-o../x). - YOLO mode (global
--yoloflag or per-channelyolo: true): Auto-approves ALL permission requests- Useful for trusted/isolated environments
- Bypasses all permission validation
Session Flow:
// 1. Create and connect agent
const connector = new AgentConnector({ agentConfig, clientConfig, skillRegistry });
await connector.connect();
// 2. Create session with workspace and optional MCP servers
const sessionId = await connector.createSession(mcpServers);
await connector.setSessionModel(sessionId, "gpt-4");
// 3. Switch to YOLO mode agent for OpenCode when YOLO is enabled
const modeOverride = getSessionModeOverride(agentType, yolo);
if (modeOverride) {
await connector.setSessionMode(sessionId, modeOverride);
}
// 4. Apply resolved reasoning effort (best-effort; no-op if unsupported)
await connector.setReasoningEffort(sessionId, resolvedReasoningEffort);
// 5. Send prompt and get response
const response = await connector.prompt(sessionId, assembledContext);
// 6. Disconnect when done
await connector.disconnect();Reasoning Effort (ACP thought_level):
Reasoning effort controls how hard the model "thinks" and is applied to the ACP session via the
Session Config Options API (session/set_config_option with the thought_level category) after
the model is set. It is resolved per session through a chain parallel to model selection:
- Per-routing-rule
reasoningEfforton a matchingagent.modelRouting.rules[]entry - Per-section
reasoningEffortonselfResearch/memoryMaintenance/conversationSummary - Global
agent.reasoningEffort(default"default")
resolveReasoningEffort() (in src/core/model-router.ts) mirrors resolveModel(): it stops at the
first matching rule; if that rule sets reasoningEffort it wins, otherwise resolution falls back
to the section/global value (it does NOT continue to later rules). This lets a rule route the model
and the effort independently while keeping them tied to the same matched rule.
| Value | Meaning |
|---|---|
"none" / "low" / "medium" / "high" |
Normalized effort levels |
"default" (or empty/unset global) |
Do not configure — let the agent/model decide |
| any other token | Agent-specific passthrough (sent as-is, warned at load) |
Behavior:
- Best-effort and non-fatal: if the agent does not advertise a
thought_leveloption, or rejects the value, the session continues. Outcomes (applied/unsupported/skipped/skipped_unavailable/failed) are logged, and the resolved effort is recorded in thesession_startaudit entry. - The connector caches the session's
configOptions(fromnewSession, refreshed byconfig_option_updatenotifications andset_config_optionresponses) and re-discovers thethought_leveloption from the latest cache at apply time — important because a model change can alter the available reasoning options. - For known-vocabulary values not offered by the model, the call is skipped with a structured warning (rather than sending an invalid value); agent-specific passthrough tokens are sent as-is.
Environment Variable Override:
AGENT_REASONING_EFFORT→agent.reasoningEffort(global default only). Per-rule values ride inMODEL_ROUTING_RULESJSON; per-section values come from the config file.
Supported Agent:
- OpenCode CLI (
opencode) - Open source coding agent that supports multiple providers:- OpenRouter provider (uses
OPENROUTER_API_KEYenv var) - Gemini provider (uses
GEMINI_API_KEYenv var) - Pre-configured in container with
agent-config/opencode.json
- OpenRouter provider (uses
Agent Selection:
- Set via
agent.defaultAgentTypein config orAGENT_DEFAULT_TYPEenv var - Valid value:
"opencode" - Container includes the pre-installed
opencodebinary
External MCP Servers:
- Configured via
agent.mcpServersin config orAGENT_MCP_SERVERSenv var (JSON string) - MCP servers are registered with the Agent during session creation
- All agents support stdio transport; HTTP/SSE support depends on Agent capabilities
- Values in
env,headers, andurlsupport${ENV_VAR}expansion
Agent Sandbox Hardening:
Agent subprocesses run with configurable sandbox isolation via SandboxManager:
| Setting | Default | Description |
|---|---|---|
agent.sandbox.filterEnv |
true |
Filter subprocess env vars to an allowed list only |
agent.sandbox.networkIsolation |
false |
Wrap command with unshare --net for network namespace isolation (Linux only) |
agent.sandbox.allowedEnvVars |
[] |
Additional env var names to pass through the filter |
agent.sandbox.allowedWriteExtensions |
[".md", ".txt"] |
Allowed file extensions for agent workspace writes in restricted mode |
The base env allowlist includes XDG_DATA_HOME; agent-factory sets it per session to
{workspace}/tmp/opencode-data/{sessionId} (session id when present, else the workspace
root) so OpenCode's data dir (truncated tool outputs, logs) stays inside the session
workspace instead of the shared ~/.local/share/opencode/.
Environment variable overrides:
| Environment Variable | Config Path | Type |
|---|---|---|
AGENT_SANDBOX_FILTER_ENV |
agent.sandbox.filterEnv |
"true" / "false" |
AGENT_SANDBOX_NETWORK_ISOLATION |
agent.sandbox.networkIsolation |
"true" / "false" |
AGENT_SANDBOX_ALLOWED_ENV_VARS |
agent.sandbox.allowedEnvVars |
Comma-separated |
AGENT_SANDBOX_ALLOWED_WRITE_EXTENSIONS |
agent.sandbox.allowedWriteExtensions |
Comma-separated |
Degradation strategy:
filterEnv: trueworks on all platforms (pure TypeScript logic)networkIsolation: truefalls back gracefully on non-Linux or whenunshareis unavailable (warns and skips)- Sandbox config errors do not prevent Agent startup
Git Credential Store for Agent:
When agent.gitCredential.enabled is true, bootstrap writes a ~/.git-credentials file and runs
git config --global credential.helper store so YOLO-mode Agent subprocesses can use plain
git push / git pull / git clone without embedding credentials in command strings.
- Credential source is shared with
gitBackup:gitBackup.authPassword→GITHUB_TOKENfor the password, andgitBackup.authUser→gitBackup.authorEmail→x-access-tokenfor the username - Host resolution order is:
agent.gitCredential.host→ parsed fromgitBackup.remoteUrl→github.com - The credential file lives under
$HOME, so it is outside ACP workspace file reads and is not injected into prompt context - In restricted mode this configuration is effectively dormant because git shell access is still blocked by the existing defense layers
- The credential store keeps git auth behavior consistent for OpenCode and avoids constructing tokenized git URLs in prompts
- Known limitation: in YOLO mode, the Agent can still read
~/.git-credentialsdirectly if it chooses to runcat ~/.git-credentials; this is an inherent YOLO trust-boundary tradeoff
Environment variable overrides:
| Environment Variable | Config Path | Type |
|---|---|---|
AGENT_GIT_CREDENTIAL_ENABLED |
agent.gitCredential.enabled |
"true" / "false" |
AGENT_GIT_CREDENTIAL_HOST |
agent.gitCredential.host |
String |
OpenCode YOLO Mode (Agent Mode Switching):
When YOLO mode is enabled for an OpenCode session, the system switches to the yolo agent
defined in agent-config/opencode.json via ACP setSessionMode("yolo"). This agent has
"*": "allow" permissions, granting the agent unrestricted access.
| Mode | OpenCode Agent | Permission Default |
|---|---|---|
| Restricted | build (default) |
"*": "deny" + whitelist |
| YOLO | yolo |
"*": "allow" |
Note: The OPENCODE_YOLO env var has been removed since upstream OpenCode YOLO mode
(PR anomalyco/opencode#11833) was never functional. YOLO is fully handled via
ACP setSessionMode("yolo").
Retry on Missing Reply:
When an ACP Agent completes a prompt turn (stopReason === "end_turn") without calling the send-reply skill, the react-message skill, or the send-file skill (a file send counts as a response — hasResponded = replySent || reactionSent || fileSent), the system automatically retries:
- Clears the reply state to allow a new reply
- Sends a second prompt on the same ACP session with a system message requesting the agent to send a reply (the retry prompt names all three communication tools and embeds the
send-fileSKILL.md alongside the others). When the session recorded permission rejections, the retry prompt additionally carries a boundedRecent permission rejections in this session:diagnostic section (snapshotted BEFORE the retryprompt()call, which runsreset()at its start) - If the retry also fails to produce a reply, reaction, or file send, the system returns a failure response
This retry mechanism uses connector.prompt() on the existing session — no CLI-level resume or loadSession()/resumeSession() is needed.
The retry strategy is configured per agent type via getRetryPromptStrategy() in src/acp/agent-factory.ts:
| Agent | Max Retries | Retry Supported |
|---|---|---|
| OpenCode | 1 | Yes |
OpenCode Version Contract:
The ACP permission request shape changed in OpenCode v1.17.13 (PR #34079 "enrich permission prompts"): edit/write requests now carry kind: "edit" with title = the target file path (the ACP ToolKind vocabulary has no "write" kind). The permission gate accepts both the new shape and the legacy title shapes ("edit", "edit_file", "write", "write_file").
- Container pin:
Containerfiledownloads a pinnedOPENCODE_VERSION(default1.17.13) with per-arch SHA-256 checksum verification (OPENCODE_SHA256_X64/OPENCODE_SHA256_ARM64) instead ofreleases/latest, so ACP contract changes cannot silently degrade the harness. Bump deliberately and re-verify the request shape + checksums. - Bootstrap check:
verifyOpenCodeVersion()(insrc/utils/opencode-version.ts) runs at bootstrap, spawnsopencode --version(5s timeout, no network, never starts an ACP session) and logs a structured, greppable marker:OpenCode version check: OK|BELOW_MINIMUM|UNKNOWN. Below-minimum or undeterminable versions only WARN — startup never blocks. The known-good minimum is1.17.13, overridable viaAGENT_OPENCODE_MIN_VERSION(documented inconfig.example.yaml,.env.example,helm/values.yaml). This is an observability measure, NOT a functional gate.
Idle Timeout Detection:
When an ACP Agent connection becomes silently unresponsive (no session updates for a configurable period), the system automatically detects and handles it:
- Activity Tracking: All Agent callbacks (sessionUpdate, requestPermission, readTextFile, writeTextFile) update a
lastActivityTimestampin ChatbotClient - Idle Monitor: During
prompt(), a periodic check runs everycheckIntervalMs(default: 30s) - Liveness Check: After
timeoutMs(default: 5 min) of inactivity:- Checks if the Agent subprocess is still alive via
process.status - Attempts
connection.cancel()as a connectivity probe - If alive → resets timer and continues waiting
- If dead → throws error for upstream handling
- Checks if the Agent subprocess is still alive via
- Session Resumption: On connection death,
SessionOrchestratorattempts to reconnect and reload the same session vialoadSession()(requires Agent support). Currently, no agents supportloadSession, so this is a forward-looking design.
| Setting | Default | Description |
|---|---|---|
agent.idleTimeout.enabled |
true |
Enable idle timeout detection |
agent.idleTimeout.timeoutMs |
300000 |
Idle timeout in ms (5 min) |
agent.idleTimeout.checkIntervalMs |
30000 |
Check interval in ms (30s) |
Environment variable overrides:
AGENT_IDLE_TIMEOUT_ENABLEDAGENT_IDLE_TIMEOUT_MSAGENT_IDLE_TIMEOUT_CHECK_INTERVAL_MS
Controls bot reply behavior through the top-level replyPolicy and channels list in config.yaml.
Reply Policy Modes:
| Mode | Behavior |
|---|---|
all |
Reply to everyone in both public channels and DMs |
public |
Reply in public channels only; DMs only if the account/channel has rateLimitBypass set |
channels |
Reply only to configured channels/accounts in the channels list (default) |
Channel ID Format:
{platform}/account/{account_ID}
{platform}/channel/{channel_ID}
Processing Order:
- Platform-level filters (bot self-check,
allowDm,respondToMention) - Reply policy (
ReplyPolicyEvaluator.shouldReply()) - Message handling and agent execution
Configuration Example:
replyPolicy: "channels"
channels:
- id: "discord/account/123456789012345678"
enabled: true
spontaneousPost: false
channelLurk: false
rateLimitBypass: false
yolo: true # Run Agent in YOLO mode for this account
- id: "discord/channel/987654321098765432"
enabled: true
spontaneousPost: true
channelLurk: true
rateLimitBypass: false
yolo: false # Default: restricted mode
- id: "misskey/account/abcdef1234567890"
enabled: true
spontaneousPost: false
channelLurk: false
rateLimitBypass: trueEnvironment Variable Overrides:
REPLY_POLICY-> setsreplyPolicy(REPLY_TO is still accepted as an alias)CHANNELS-> setschannels(JSON array, fully replaces config file value)
REPLY_POLICY=public
CHANNELS='[{"id":"discord/account/12345678901234567","enabled":true}]'Prevents excessive API usage per user via a sliding window + cooldown mechanism. Complements access control: access control decides "who can", rate limiting decides "how often".
Configuration:
rateLimit:
enabled: false
maxRequestsPerWindow: 10
windowMs: 600000 # 10-minute sliding window
cooldownMs: 600000 # Cooldown after limit exceededHow It Works:
- Each user is tracked independently by
{platform}:{userId}key - Requests within the sliding window are counted
- When
maxRequestsPerWindowis exceeded, the user enters a cooldown period - During cooldown, all requests are silently rejected (no reply, no session started)
- After cooldown expires, the counter resets and the user can send requests again
- Rate limit check runs after duplicate event detection and before any resource allocation
- Whitelisted accounts (
{platform}/account/{id}) automatically bypass rate limiting. Whitelisted channels ({platform}/channel/{id}) do not affect rate limiting — users in whitelisted channels are still subject to rate limits.
Environment Variable Overrides:
RATE_LIMIT_ENABLED→rateLimit.enabledRATE_LIMIT_MAX_REQUESTS_PER_WINDOW→rateLimit.maxRequestsPerWindowRATE_LIMIT_WINDOW_MS→rateLimit.windowMsRATE_LIMIT_COOLDOWN_MS→rateLimit.cooldownMs
Enables the bot to post messages/notes on its own schedule without user triggers.
Configuration (per-platform):
platforms:
discord:
spontaneousPost:
enabled: false # Enable spontaneous posting (default: false)
minIntervalMs: 10800000 # Minimum interval: 3 hours (default)
maxIntervalMs: 43200000 # Maximum interval: 12 hours (default)
contextFetchProbability: 0.5 # Probability of including recent messages (0.0-1.0)How It Works:
SpontaneousSchedulermanages per-platform independent timers- Each execution picks a random interval between min and max
- On trigger, the scheduler:
- Determines a target from channels configured with
spontaneousPost: true - Randomly decides whether to fetch recent messages based on
contextFetchProbability - Calls
SessionOrchestrator.processSpontaneousPost()to run the agent
- Determines a target from channels configured with
- The agent receives a special prompt instructing it to create original content
- Errors never crash the bot — the next execution is always scheduled
Platform Target Selection:
| Platform | Target Selection |
|---|---|
| Discord | Random channel from channels with spontaneousPost: true (account entries create DM targets) |
| Misskey | Random channel from channels with spontaneousPost: true (supports misskey/timeline/self) |
Environment Variable Overrides:
DISCORD_SPONTANEOUS_ENABLED→platforms.discord.spontaneousPost.enabledDISCORD_SPONTANEOUS_MIN_INTERVAL_MS→platforms.discord.spontaneousPost.minIntervalMsDISCORD_SPONTANEOUS_MAX_INTERVAL_MS→platforms.discord.spontaneousPost.maxIntervalMsDISCORD_SPONTANEOUS_CONTEXT_FETCH_PROBABILITY→platforms.discord.spontaneousPost.contextFetchProbability- Same pattern for Misskey with
MISSKEY_SPONTANEOUS_*prefix
Key Components:
src/core/spontaneous-scheduler.ts— Timer management and executionsrc/core/spontaneous-target.ts— Platform-specific target determinationSessionOrchestrator.processSpontaneousPost()— Triggerless session flowContextAssembler.assembleSpontaneousContext()— Context assembly without trigger message
Periodically checks whitelisted Discord channels and auto-replies when conditions are met. Discord only.
Configuration:
platforms:
discord:
channelLurk:
enabled: false # Enable channel lurk reply (default: false)
intervalMs: 1800000 # Check interval: 30 minutes (default)Environment Variable Overrides:
| Environment Variable | Config Path |
|---|---|
DISCORD_CHANNEL_LURK_ENABLED |
platforms.discord.channelLurk.enabled |
DISCORD_CHANNEL_LURK_INTERVAL_MS |
platforms.discord.channelLurk.intervalMs |
Trigger Conditions (all must be true):
- Last message sender is not the bot itself (
isSelf()) - Last message does not mention the bot (
hasBotMention()) - Bot has not reacted to the last message (
hasBotReaction()) - Message has not been processed before (
lastProcessedMessageIdmap)
Differences from Spontaneous Posting:
| Aspect | Spontaneous Post | Channel Lurk Reply |
|---|---|---|
| Trigger | Random interval | Fixed interval + condition check |
| Target | Random whitelist entry | All whitelist channels |
| Trigger message | None (self-initiated) | Last message in channel |
| Session type | spontaneous |
channelLurk |
| Prompt template | system_spontaneous.md |
system_reply.md (reuse) |
| Platform support | Discord + Misskey | Discord only |
Key Components:
src/core/channel-lurk-scheduler.ts— Timer and condition checkingSessionOrchestrator.processChannelLurkMessage()— Reuses normal message flow
Enables the agent to periodically read RSS feeds, pick a topic as its character, research it, and write study notes to the agent workspace.
Configuration:
selfResearch:
enabled: false
model: "gpt-5-mini"
rssFeeds:
- url: "https://example.com/feed.xml"
name: "Tech News"
minIntervalMs: 43200000 # 12 hours
maxIntervalMs: 86400000 # 24 hoursEnvironment Variable Overrides:
SELF_RESEARCH_ENABLED→selfResearch.enabledSELF_RESEARCH_MODEL→selfResearch.modelSELF_RESEARCH_RSS_FEEDS→selfResearch.rssFeeds(JSON string)SELF_RESEARCH_MIN_INTERVAL_MS→selfResearch.minIntervalMsSELF_RESEARCH_MAX_INTERVAL_MS→selfResearch.maxIntervalMs
How It Works:
SelfResearchSchedulermanages a timer with random intervals (12-24h default)- On trigger: fetch RSS items → randomly pick 20 → build research prompt
- Agent receives prompt with character personality and RSS materials
- Agent checks existing notes, picks a new topic, researches via web tools
- Agent writes notes to
$AGENT_WORKSPACE/notes/and updates_index.md - Agent self-reviews for hallucinations and privacy
- No reply is sent to any platform — purely internal research
Note: Self-research sessions set
canWriteAgentWorkspace: truein template variables, allowing the prompt template to show write instructions (instead of "read-only") when renderingagent_workspace.md. Combined with the rawInput path extraction inrequestPermission(), the agent can write research notes to$AGENT_WORKSPACE/notes/even in restricted (non-YOLO) mode, as long as the edit/write permission request includes file path information.
Key Components:
src/core/self-research-scheduler.ts— Timer managementsrc/utils/rss-fetcher.ts— RSS/Atom feed fetching and parsingSessionOrchestrator.processSelfResearch()— Research session flowprompts/system_self_research.md— Research prompt template
Enables periodic, agent-driven memory summarization/compaction per user workspace to control long-term memory growth.
Configuration:
memoryMaintenance:
enabled: false
model: "gpt-5-mini"
minMemoryCount: 50
intervalMs: 604800000 # 7 daysEnvironment Variable Overrides:
MEMORY_MAINTENANCE_ENABLED→memoryMaintenance.enabledMEMORY_MAINTENANCE_MODEL→memoryMaintenance.modelMEMORY_MAINTENANCE_MIN_MEMORY_COUNT→memoryMaintenance.minMemoryCountMEMORY_MAINTENANCE_INTERVAL_MS→memoryMaintenance.intervalMs
How It Works:
MemoryMaintenanceSchedulertriggers at fixed intervals- All workspaces are scanned, and low-memory workspaces are skipped by threshold
SessionOrchestrator.processMemoryMaintenance()runs one ACP session per workspace- Agent uses existing memory skills (
memory-search,memory-save,memory-patch) - Original memories are disabled via patch events (append-only preserved)
- Failures are isolated per workspace and do not stop the full maintenance cycle
Supports passing image and file attachments from platform messages to the ACP Agent.
Flow:
Trigger/history message → Platform adapter extracts attachment metadata (URL, mimeType, filename, size)
→ NormalizedEvent / PlatformMessage carry attachments[]
→ ContextAssembler formats attachment info as text descriptions (always)
→ SessionOrchestrator builds image ContentBlock (when Agent supports promptCapabilities.image)
→ AgentConnector.prompt() sends mixed content (text + image ContentBlock)
Key Design Points:
- Attachment type:
Attachmentinterface insrc/types/events.tswithisImageflag (MIME starts withimage/) - Capability negotiation: Only sends image
ContentBlockwhen Agent reportspromptCapabilities.image === true - Text description always present: Attachment URLs and metadata are always included as text in context, regardless of image capability
- Only trigger message images downloaded: History message images are described by URL only (no download)
- Size limit: Images over 20MB are not downloaded; described by URL instead
- Download timeout: 10 seconds per image; failures are non-fatal
- SSRF protection: Every attachment URL is validated at the download sink via
safeFetch(src/utils/ssrf.ts) before each request: scheme must be http/https; the host must resolve to a public address (loopback / private / link-local / ULA / unspecified / multicast are rejected); redirects are followed manually with per-hop re-validation up to a max of 5 hops. Validation failures are non-fatal and fall back to the URL-only text description. - Backward compatible:
attachmentsfield is optional; no changes to existing behavior for text-only messages
Platform Attachment Sources:
| Platform | Source | Field |
|---|---|---|
| Discord | message.attachments (Collection) |
id, url, contentType, name, size, width, height |
| Discord | message.stickers (Collection) |
Formatted as [Sticker: name (tags)] in content |
| Misskey Note | note.files (DriveFile[]) |
id, url, type, name, size, properties.width/height |
| Misskey Chat | message.file (DriveFile | null) |
Same as above |
Exposes operational metrics via a Prometheus-compatible /metrics endpoint on the existing Health Check Server.
Configuration:
metrics:
enabled: false # Enable Prometheus metrics endpoint (default: false)
path: "/metrics" # Metrics endpoint path (default: "/metrics")Environment Variable Overrides:
METRICS_ENABLED→metrics.enabledMETRICS_PATH→metrics.path
Exposed Metrics:
| Metric Name | Type | Labels | Description |
|---|---|---|---|
airfriends_sessions_total |
Counter | platform, type, status |
Total sessions (success/failure) |
airfriends_session_duration_seconds |
Histogram | platform, type, status |
Session processing time |
airfriends_active_sessions |
Gauge | — | Currently active sessions |
airfriends_messages_received_total |
Counter | platform |
Messages received from platforms |
airfriends_replies_sent_total |
Counter | platform |
Replies sent to platforms |
airfriends_memory_operations_total |
Counter | operation, visibility |
Memory operations count |
airfriends_skill_api_calls_total |
Counter | skill, status |
Skill API call count |
airfriends_rate_limit_rejections_total |
Counter | platform |
Rate limit rejections |
airfriends_audit_entries_total |
Counter | phase |
Audit log entries written |
Key Design Points:
- Uses
prom-client(npm) with a dedicated Registry for test isolation - Shares the Health Check Server port — no additional port needed
- All metric operations are pure in-memory O(1) with no I/O overhead
- Metrics endpoint only exposes aggregate numbers, never user content or tokens
Periodically backs up the data/ directory to a remote GitHub repository using Git.
Configuration:
gitBackup:
enabled: false
remoteUrl: ""
intervalMs: 3600000
authorName: "AIr-Friends Backup"
authorEmail: "airfriends-backup@noreply.github.com"
authUser: ""
authPassword: ""Environment Variable Overrides:
GIT_BACKUP_ENABLED→gitBackup.enabledGIT_BACKUP_REMOTE_URL→gitBackup.remoteUrlGIT_BACKUP_INTERVAL_MS→gitBackup.intervalMsGIT_BACKUP_AUTHOR_NAME→gitBackup.authorNameGIT_BACKUP_AUTHOR_EMAIL→gitBackup.authorEmailGIT_BACKUP_AUTH_USER→gitBackup.authUserGIT_BACKUP_AUTH_PASSWORD→gitBackup.authPassword
How It Works:
GitBackupSchedulertriggers backup at fixed intervalsGitBackupService.initialize()intelligently initializes based on directory state at startup:- Empty directory: clone the remote repository
- Non-empty non-Git directory: git init + commit + push
- Existing Git repo: commit uncommitted changes + push
- Push conflicts during initialization trigger automatic rebase retry; if that also fails, a
backup-{datetime}fallback branch is created and pushed GitBackupService.performBackup()executes add → commit → push- Authentication uses configurable credentials (
GIT_BACKUP_AUTH_USER/GIT_BACKUP_AUTH_PASSWORD), falling back toGITHUB_TOKENfor backward compatibility - A final backup is performed during graceful shutdown
- Push conflicts during periodic backup trigger an automatic
pull --rebaseand one retry
Key Components:
src/core/git-backup-service.ts— Git operation encapsulationsrc/core/git-backup-scheduler.ts— Fixed-interval scheduling
Enables automatic installation of external Agent Skills at startup via npx --yes --package=skills skills add.
Configuration:
agent:
externalSkills:
- repo: "jim60105/copilot-prompt"
skill: "create-blog-post"Environment Variable Override:
AGENT_EXTERNAL_SKILLS→agent.externalSkills(JSON string, e.g.[{"repo":"owner/repo","skill":"skill-name"}])
How It Works:
- Configured in
config.yamlunderagent.externalSkillsor viaAGENT_EXTERNAL_SKILLSenv var installExternalSkills()runs duringbootstrap(), after config loading and beforeAgentCoreinitialization- Skills are installed sequentially to avoid filesystem conflicts in
~/.agents/skills/ - Each skill is installed via
npx --yes --package=skills skills add <repo> -a universal -s <skill> -g -y - Individual installation failures are logged but do not block application startup
Key Components:
src/core/skill-installer.ts— Sequential skill installation logicsrc/types/config.ts—ExternalSkillConfiginterfacesrc/utils/env.ts—AGENT_EXTERNAL_SKILLSJSON parsingsrc/core/config-loader.ts— Validation (filters invalid entries, defaults to empty array)
Per-session JSONL audit trail for replay and debugging. Each session writes timestamped entries tracking the full lifecycle from trigger receipt through context assembly, agent interaction, skill calls, replies, and session end.
Audit Phases:
| Phase | Description |
|---|---|
trigger_received |
Incoming trigger event recorded |
session_start |
Session registered and configured |
rate_limit_checked |
Rate limit evaluation result |
context_assembly |
Context assembly completed |
yolo_resolution |
YOLO mode resolution |
agent_connect |
Agent subprocess connected |
prompt_sent |
Prompt sent to agent |
agent_message |
Full prompt/context sent to agent |
skill_call |
Skill API invoked |
memory_operation |
Memory skill operation |
agent_response |
Agent response received |
agent_complete_message |
Agent complete buffered response |
reply_sent |
Reply sent to platform |
reply_edited |
Reply edited on platform |
retry_triggered |
Missing-reply retry activated |
session_end |
Session lifecycle completed |
permission_approved |
Permission request approved |
permission_denied |
Permission request denied |
Configuration:
audit:
enabled: false
retentionDays: 7
hashContent: true
includedPhases:
- "trigger_received"
- "skill_call"
- "reply_sent"
- "session_end"Environment Variable Overrides:
| Environment Variable | Config Path | Type |
|---|---|---|
AUDIT_ENABLED |
audit.enabled |
"true" / "false" |
AUDIT_RETENTION_DAYS |
audit.retentionDays |
Integer string |
AUDIT_HASH_CONTENT |
audit.hashContent |
"true" / "false" |
AUDIT_INCLUDED_PHASES |
audit.includedPhases |
Comma-separated |
How It Works:
SessionAuditWriteris created per-session when audit is enabled- Entries are written as append-only JSONL to
data/audit/{platform}/{userId}/{sessionId}.jsonl write()is fire-and-forget — I/O errors never crash the session- Phase filtering: when
includedPhasesis non-empty, only matching phases are recorded - Content hashing: when
hashContentis true, user content fields are SHA-256 hashed viasanitizeSkillParams() - Retention cleanup runs at startup and every 24 hours, deleting files older than
retentionDays - Skill call auditing is done in the Skill API Server (
src/skill-api/server.ts)
Phase-Specific SessionAuditEntry.data Fields:
trigger_received:platform,channelId,userId,messageId,isDm,contentLength,attachmentCountsession_start:sessionId,sessionType,workspaceKey,agentType,model,yolorate_limit_checked:decision,userId,platform,requestCount,maxRequests,cooldownRemainingMscontext_assembly:memoriesCount,recentMessagesCount,relatedMessagesCount,estimatedTokensagent_connect:agentType,capabilitiesprompt_sent:promptLength,imageCount,modelIdagent_message:promptContentHash,promptLengthskill_call:skillName,skillParams,skillResult,skillDurationMsmemory_operation:operation,memoryId,visibility,tier,category,resultCountagent_response:stopReason,isRetryagent_complete_message:messageContentHash,messageLength,chunkCountreply_sent:replyContentHash,replyLength,platformreply_edited:originalMessageId,newMessageId,replyContentHash,replyLength,platformretry_triggered:retryCount,maxRetries,reasonsession_end:success,replySent,reactionSent,durationMs,error,repliesCount,skillCallsCount,memoryOpsCount,permissionDecisionsCountpermission_approved/permission_denied:toolName,permissionKind,command,decision,reason
Key Components:
src/types/audit.ts—AuditPhaseandSessionAuditEntrytypessrc/core/audit-logger.ts—SessionAuditWriter(per-session JSONL writer)src/core/audit-retention.ts—cleanupAuditLogs()(retention cleanup)src/core/audit-retention-scheduler.ts—AuditRetentionScheduler(24h periodic cleanup)src/utils/hash.ts—sha256Hash()andsanitizeSkillParams()
The system uses Vento as its prompt template engine, allowing easy customization without rebuilding containers.
The main system prompt (prompts/system_reply.md) uses Vento syntax. Fragment files are loaded via {{ include }} and assigned to variables with {{ set }}. The loadSystemPrompt function (in src/core/config-loader.ts) creates a Vento environment pointing at the prompts directory, then renders the template with context variables (platform, isDm, sessionId, etc.).
Example:
<!-- prompts/system_reply.md -->
{{- set charName }}{{ include "./character_name.md" }}{{ /set -}}
You are {{ charName }}. {{ if isDm }}This is a private chat.{{ /if }}<!-- prompts/character_name.md -->
YunaResult (isDm=true):
You are Yuna. This is a private chat.
| Rule | Behavior |
|---|---|
| Variable format | {{ variableName }} outputs a variable value |
| Include syntax | {{ include "./filename.md" }} loads a file from prompts directory |
| Conditionals | {{ if condition }}...{{ else }}...{{ /if }} |
| Set variables | {{ set name }}...{{ /set }} assigns content to a variable |
| Content trimming | {{- ... -}} removes surrounding whitespace |
| Missing includes | Throws an error with the missing file name |
| Final result | Rendered output is trimmed of leading/trailing whitespace |
| Comments | {{# comment #}} is excluded from output |
| Variable | Type | Description |
|---|---|---|
isDm |
boolean |
Whether this is a direct message conversation |
platform |
string |
Platform name ("discord" / "misskey") |
userId |
string |
User's platform ID |
channelId |
string |
Channel/conversation ID |
guildId |
string |
Server/guild ID (empty string if N/A) |
sessionId |
string |
Current skill API session ID |
agentType |
string |
ACP agent type ("opencode") |
model |
string |
Model identifier (e.g., "openrouter/deepseek/deepseek-v4-pro") |
rssItems |
string |
RSS items (self-research prompt only) |
workspaceKey |
string |
Workspace key (memory maintenance prompt only) |
memoriesDump |
string |
Memory JSON dump (memory maintenance only) |
minMemoryCount |
number |
Minimum memory count threshold (memory maintenance prompt only) |
recentMessagesFetched |
boolean |
Whether recent messages were fetched (spontaneous post only) |
importantMemories |
string |
Formatted important memories text (spontaneous post only) |
recentMessages |
string |
Formatted recent messages text (spontaneous post only) |
availableEmojis |
string |
Formatted available emojis text (spontaneous post only) |
yolo |
boolean |
Whether YOLO mode is enabled (bypasses permission restrictions) |
canWriteAgentWorkspace |
boolean |
Whether this session allows writing to agent workspace |
userContextMessage |
string |
Pre-formatted user context message (normal message prompt only) |
Default Prompts:
- Default prompt files are bundled in the container at
/app/prompts/ - The container declares
/app/promptsas a VOLUME for optional overrides
Custom Prompts:
- Users can mount individual prompt files to
/app/prompts/<filename>:rowithout rebuilding - Only the files you mount will be overridden; others keep their container defaults
- No need to provide all files — unmounted files retain the bundled defaults
Container Binaries:
- The container includes pre-installed binaries:
opencode- OpenCode CLI (latest release)rg- ripgrep 15.1.0 for memory searchdumb-init- Used as PID 1 and to wrap agent subprocesses for proper signal forwarding
- OpenCode configuration is pre-configured at
/home/deno/.config/opencode/opencode.json - Skills are copied to
/home/deno/.agents/skills/for agent discovery
Example compose.yml:
volumes:
- ./data:/app/data:Z
- ./config.yaml:/app/config.yaml:ro,Z
# Mount only the prompt files you want to override
- ./my-prompts/character_name.md:/app/prompts/character_name.md:ro,Z
- ./my-prompts/character_info.md:/app/prompts/character_info.md:ro,ZTo use new variables or conditionals in your templates:
- Use
{{ variableName }}directly in your template — available variables are listed above - Use
{{ include "./fragment.md" }}to include other files from the prompts directory - Use
{{ if condition }}...{{ /if }}for conditional rendering - No code changes needed — Vento supports arbitrary JavaScript expressions
- Test locally before deploying to containers
- Template Renderer:
src/core/template-renderer.ts(createTemplateEngine,renderTemplate,renderTemplateString) - Config Loader:
src/core/config-loader.ts(loadSystemPrompt) - Type Definitions:
src/types/template.ts(TemplateVariables) - Tests:
tests/core/template-renderer.test.ts,tests/core/config-loader.test.ts - OpenSpec Spec:
openspec/specs/prompt-template-system/spec.md
Use the unified error class hierarchy:
| Error Class | Use Case |
|---|---|
ConfigError |
Configuration issues |
PlatformError |
Platform API failures |
AgentError |
Agent execution errors |
MemoryError |
Memory file I/O errors |
SkillError |
Skill execution errors |
WorkspaceError |
Workspace access violations |
import { ConfigError, ErrorCode } from "@types/errors.ts";
throw new ConfigError(
ErrorCode.CONFIG_MISSING_FIELD,
"Missing required field: platforms.discord.token",
{ field: "platforms.discord.token" },
);Important: Single session errors must NOT crash the entire bot.
Use structured JSON logging via @utils/logger.ts:
import { createLogger } from "@utils/logger.ts";
const logger = createLogger("ModuleName");
logger.info("Operation completed", { userId, channelId });
logger.error("Operation failed", { error: err.message });Never log sensitive information (tokens, passwords, private message content).
日誌訊息應使用 Message Template 語法 {PropertyName} 引用 context 中的結構化屬性。Logger 會自動將 {PropertyName} 替換為 context 中對應的值,同時保留原始模板作為 messageTemplate 欄位供日誌系統分類使用。
// ✅ 正確 — 使用 {PropertyName} 引用 context 屬性
logger.info("Session {sessionId} model set to {modelId}", { sessionId, modelId });
// ❌ 錯誤 — 靜態訊息未包含主要識別符
logger.info("Session model set", { sessionId, modelId });
// ❌ 錯誤 — 使用 template literal 而非 message template(會破壞事件分類)
logger.info(`Session ${sessionId} model set to ${modelId}`, { sessionId, modelId });規則:
{PropertyName}中的名稱必須與 context 物件的 key 完全一致- 未匹配的佔位符保持原樣不替換
- 使用
{{和}}來跳脫字面大括號 - 不包含
{PropertyName}的訊息不會產生messageTemplate欄位(完全向後相容) null/undefined值替換為空字串;物件型別使用JSON.stringify()序列化
When logging.gelf.enabled is true and logging.gelf.endpoint is set, all log entries are also sent to a GELF HTTP endpoint via fire-and-forget fetch(). The GELF transport is initialized in bootstrap.ts and injected into the global logger config. The transport module is at src/utils/gelf-transport.ts.
GELF 輸出中,messageTemplate 會作為 _messageTemplate 自訂欄位傳送。
- Unit tests:
{module}.test.ts - Integration tests:
{feature}.integration.test.ts - Use
Deno.test()with@std/assert - Test coverage MUST be over 75% — CI enforces this threshold; PRs below 75% coverage will fail
import { assertEquals } from "@std/assert";
Deno.test("WorkspaceManager - generates correct workspace key", () => {
const key = getWorkspaceKey({
platform: "discord",
user_id: "123",
});
assertEquals(key, "discord/123");
});Configuration file: config.yaml
platforms:
discord:
token: "${DISCORD_TOKEN}" # Environment variable reference
enabled: true
misskey:
host: "${MISSKEY_HOST}"
token: "${MISSKEY_TOKEN}"
enabled: false
agent:
model: "gpt-4"
system_prompt_path: "./prompts/system_reply.md"
token_limit: 4096
# External skills to install at startup (optional)
# externalSkills:
# - repo: "jim60105/copilot-prompt"
# skill: "create-blog-post"
# External MCP servers (optional, see config.example.yaml for full examples)
# mcpServers:
# - name: "github"
# command: "npx"
# args: ["-y", "@modelcontextprotocol/server-github"]
# env:
# GITHUB_TOKEN: "${GITHUB_TOKEN}"
memory:
search_limit: 10
max_chars: 2000
workspace:
repo_path: "./data"
workspaces_dir: "workspaces"
replyPolicy: "channels"
channels:
- id: "discord/account/12345678901234567"
rateLimitBypass: true
yolo: true # Run Agent in YOLO mode for this account
dashboard:
enabled: false
port: 8090
host: "127.0.0.1" # Bind host; default localhost. Set "0.0.0.0" to expose on all interfaces (DASHBOARD_HOST)
passphrase: "" # Required when enabled; minimum 16 characters (DASHBOARD_PASSPHRASE)
behindHttpsProxy: false # Enables the Secure cookie flag; NOT derived from X-Forwarded-Proto (DASHBOARD_BEHIND_HTTPS_PROXY)
trustedProxies: [] # Real connection addresses whose X-Forwarded-For is trusted for login rate-limit keying (DASHBOARD_TRUSTED_PROXIES)Dashboard security defaults: the dashboard binds to 127.0.0.1 by default and requires an explicit 0.0.0.0 to expose on all interfaces. The login rate limit keys on the real connection address (not the spoofable X-Forwarded-For) unless the peer is in trustedProxies, with an additional global backoff. A minimum 16-character passphrase is enforced at config load when the dashboard is enabled. The session cookie's Secure flag is driven by behindHttpsProxy, never by request headers.
Environment variables override config file values.
AIr-Friends/
├── src/
│ ├── main.ts # Entry point
│ ├── bootstrap.ts # Application bootstrap
│ ├── shutdown.ts # Graceful shutdown handler
│ ├── healthcheck.ts # Health check server (optional)
│ ├── acp/ # ACP Client integration
│ │ ├── agent-connector.ts # Manages ACP agent subprocess
│ │ ├── agent-factory.ts # Creates agent configurations
│ │ ├── client.ts # ChatbotClient (implements ACP Client)
│ │ └── types.ts # ACP-related types
│ ├── core/
│ │ ├── agent-core.ts # Main integration point
│ │ ├── session-orchestrator.ts # Conversation flow orchestration
│ │ ├── workspace-manager.ts # Workspace isolation manager
│ │ ├── memory-store.ts # Memory JSONL operations
│ │ ├── context-assembler.ts # Initial context assembly
│ │ ├── message-handler.ts # Platform event processing
│ │ ├── reply-dispatcher.ts # Reply sending coordination
│ │ ├── reply-policy.ts # Access control & reply policy
│ │ ├── spontaneous-scheduler.ts # Spontaneous posting scheduler
│ │ ├── spontaneous-target.ts # Platform-specific target selection
│ │ ├── channel-lurk-scheduler.ts # Channel lurk reply scheduler (Discord only)
│ │ ├── self-research-scheduler.ts # Self-research scheduling
│ │ ├── memory-maintenance-scheduler.ts # Memory maintenance scheduling
│ │ ├── audit-logger.ts # Session audit JSONL writer
│ │ ├── audit-retention.ts # Audit log retention cleanup
│ │ ├── audit-retention-scheduler.ts # Audit retention scheduling
│ │ ├── skill-installer.ts # External skill auto-installation
│ │ └── config-loader.ts # Configuration loading
│ ├── platforms/
│ │ ├── platform-adapter.ts # Platform adapter base class
│ │ ├── platform-registry.ts # Platform management
│ │ ├── discord/ # Discord implementation
│ │ │ ├── discord-adapter.ts
│ │ │ ├── discord-config.ts
│ │ │ └── discord-utils.ts
│ │ └── misskey/ # Misskey implementation
│ │ ├── misskey-adapter.ts
│ │ ├── misskey-client.ts
│ │ ├── misskey-config.ts
│ │ └── misskey-utils.ts
│ ├── skills/ # Internal skill handlers
│ │ ├── registry.ts # Skill handler registry
│ │ ├── memory-handler.ts # Memory operations
│ │ ├── reply-handler.ts # Reply sending (single reply rule)
│ │ ├── context-handler.ts # Context fetching
│ │ └── types.ts # Skill-related types
│ ├── skill-api/ # HTTP API for shell skills
│ │ ├── server.ts # HTTP server implementation
│ │ └── session-registry.ts # Active session tracking
│ ├── types/
│ │ ├── config.ts # Configuration types
│ │ ├── events.ts # Event types
│ │ ├── memory.ts # Memory types
│ │ ├── workspace.ts # Workspace types
│ │ ├── platform.ts # Platform types
│ │ ├── errors.ts # Error classes
│ │ ├── audit.ts # Audit log types
│ │ └── logger.ts # Logger types
│ └── utils/
│ ├── logger.ts # Structured JSON logging
│ ├── rss-fetcher.ts # RSS/Atom feed fetching and parsing
│ ├── hash.ts # SHA-256 hashing and content sanitization
│ └── env.ts # Environment utilities
├── skills/ # Shell-based skill scripts
│ ├── memory-save/
│ │ ├── SKILL.md # Skill definition for agent
│ │ └── scripts/
│ │ └── send-reply.ts # Deno script
│ ├── memory-search/
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ └── memory-search.ts
│ ├── memory-patch/
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ └── memory-patch.ts
│ ├── memory-stats/
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ └── memory-stats.ts
│ ├── fetch-context/
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ └── fetch-context.ts
│ ├── send-reply/
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ └── send-reply.ts
│ ├── edit-reply/
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ └── edit-reply.ts
│ └── lib/
│ └── client.ts # Shared skill API client
├── prompts/
│ └── system_reply.md # Bot system prompt
├── config/
│ └── config.example.yaml # Example configuration
├── docs/
│ ├── DESIGN.md # Detailed design document
│ ├── SKILLS_IMPLEMENTATION.md # Skills implementation guide
│ └── features/ # BDD feature specs (Gherkin)
├── tests/ # Test files (mirrors src/ structure)
│ ├── core/
│ ├── acp/
│ ├── platforms/
│ ├── skills/
│ ├── skill-api/
│ ├── integration/
│ ├── mocks/
│ └── main.test.ts
├── agent-config/ # Agent CLI configuration files
│ └── opencode.json # OpenCode CLI configuration
├── deno.json # Deno configuration
├── deno.lock # Dependency lock file
├── config.yaml # Runtime configuration
└── Containerfile # Container build definition
Before committing, ensure:
- ✅
deno fmt --check src/ tests/passes - ✅
deno lint src/ tests/passes - ✅
deno check src/main.tspasses - ✅
deno testpasses - ✅ Test coverage is over 75%
- ✅ All CI checks pass — PRs will only be merged after all CI jobs succeed
- ✅ No sensitive data in code or logs
- ✅ When adding new configuration or environment variables, update all of:
config.example.yaml.env.examplehelm/values.yaml(underenv:section)
- docs/DESIGN.md - Detailed design document
- docs/DEVELOPMENT.md - Development setup and customization guide
- docs/PLATFORM_INTEGRATION.md - Guide for adding new platform support
- openspec/specs/ - OpenSpec specifications
- ACP Protocol Spec - Agent Client Protocol
- Agent Skills Standard - SKILL.md format