This guide explains how to add a new platform to AIr-Friends. It covers everything from creating the adapter to configuration, testing, and registration.
AIr-Friends uses a platform adapter architecture where each supported platform (Discord, Misskey, etc.) implements the abstract PlatformAdapter class (src/platforms/platform-adapter.ts). The adapter is responsible for:
- Connecting to and disconnecting from the platform API
- Converting platform-specific messages into
NormalizedEvent/PlatformMessageformats - Sending replies, reactions, and typing indicators
- Fetching message history and custom emojis
- Determining targets for spontaneous posts
For the overall architecture, see the diagram in AGENTS.md.
- Deno 2.x development environment
- Basic understanding of the target platform's API/SDK
- Familiarity with TypeScript and
async/await
Throughout this guide, replace {platform} with the lowercase platform name (e.g., slack) and {Platform} with the PascalCase name (e.g., Slack).
Create the following files under src/platforms/{platform}/:
| File | Purpose |
|---|---|
{platform}-adapter.ts |
Main adapter extending PlatformAdapter |
{platform}-config.ts |
Configuration types, defaults, and channel config pattern |
{platform}-utils.ts |
Message conversion helpers |
{platform}-client.ts |
Platform API client wrapper (if needed) |
index.ts |
Barrel export |
Reference implementations: src/platforms/discord/, src/platforms/misskey/.
Your adapter must extend PlatformAdapter (which also implements MessageFetcher).
| Property | Type | Description |
|---|---|---|
platform |
Platform |
Platform identifier string (e.g., "slack") |
capabilities |
PlatformCapabilities |
Feature flags for the platform |
PlatformCapabilities fields (from src/types/platform.ts):
interface PlatformCapabilities {
canFetchHistory: boolean; // Can fetch message history
canSearchMessages: boolean; // Can search messages
supportsDm: boolean; // Supports direct messages
supportsGuild: boolean; // Supports guild/server concept
supportsReactions: boolean; // Supports message reactions
maxMessageLength: number; // Maximum message length
}| Method | Signature | Purpose | Notes |
|---|---|---|---|
connect() |
(): Promise<void> |
Connect to the platform API | Update connection state via updateConnectionState() |
disconnect() |
(): Promise<void> |
Disconnect from the platform | Clean up resources |
sendTyping(channelId) |
(channelId: string): Promise<void> |
Send typing indicator | Implement as no-op if unsupported |
sendReply(channelId, content, options?) |
See source | Send a reply to a channel | Returns ReplyResult with messageId |
editMessage(channelId, messageId, newContent, replyToMessageId?) |
See source | Edit a previously sent message | Misskey uses delete-and-recreate |
sendFile(channelId, fileContent, fileName, options?) |
See source | Send a file attachment | Returns SendFileResult |
fetchRecentMessages(channelId, limit) |
(channelId: string, limit: number): Promise<PlatformMessage[]> |
Fetch recent channel messages | Part of MessageFetcher interface |
fetchEmojis() |
(): Promise<PlatformEmoji[]> |
Fetch available custom emojis | Cache results to reduce API calls |
addReaction(channelId, messageId, emoji) |
See source | Add a reaction to a message | Returns ReactionResult |
getUsername(userId) |
(userId: string): Promise<string> |
Get display name for a user ID | — |
isSelf(userId) |
(userId: string): boolean |
Check if user ID is the bot | — |
getBotId() |
(): string | null |
Get the bot's user ID | null if not yet connected |
getDmChannelId(userId) |
(userId: string): Promise<string | null> |
Get or create a DM channel | Discord: User.createDM(), Misskey: chat:{userId} |
hasBotReaction(channelId, messageId) |
See source | Check if bot already reacted | Used by channel lurk scheduler |
hasBotMention(channelId, messageId) |
See source | Check if message mentions bot | Used by channel lurk scheduler |
fetchMessage(channelId, messageId) |
(channelId: string, messageId: string): Promise<PlatformMessage | null> |
Fetch a single message by ID | Returns null if not found |
determineSpontaneousTarget(config) |
(config: Config): Promise<SpontaneousTarget | null> |
Select target for spontaneous post | Discord: random channel/account from channels list; Misskey: timeline:self |
| Method | Default | Override When |
|---|---|---|
getSearchGuildId(channelId, isDm) |
Returns "" |
Platform has guild/server concept (e.g., Discord returns guild ID) |
supportsTypingIndicator() |
Returns false |
Platform supports and has enabled typing indicators |
searchRelatedMessages(guildId, channelId, query, limit) |
Not defined (optional) | Platform supports message search |
The EventRouter (src/core/event-router.ts) routes NormalizedEvent instances to handlers based on condition predicates, evaluated in registration order. If no condition matches, an optional default fallback handler is used. Errors in one handler do not affect other routes.
Predefined condition helpers:
| Helper | Description |
|---|---|
isDmEvent |
Match DM events only |
isGuildEvent |
Match guild/server events only |
isPlatform(...platforms) |
Match specific platforms |
containsKeyword(...keywords) |
Match messages containing keywords |
allOf(...conditions) |
Combine conditions with AND logic |
anyOf(...conditions) |
Combine conditions with OR logic |
Connect the router to a PlatformRegistry via connectToRegistry(registry) to automatically route all incoming platform events.
Modify src/types/config.ts:
- Add a
{Platform}AdapterConfiginterface (referenceDiscordAdapterConfigorMisskeyAdapterConfig). - Add the corresponding field in
PlatformsConfig:
export interface PlatformsConfig {
discord: DiscordAdapterConfig;
misskey: MisskeyAdapterConfig;
{platform}: {Platform}AdapterConfig; // Add this
}Create src/platforms/{platform}/{platform}-config.ts:
- Define default config values (
DEFAULT_{PLATFORM}_CONFIG) - Define the channel config validation pattern:
// Format: {platform}/(account|channel)/{id}
export const {PLATFORM}_CHANNEL_PATTERN = /^{platform}\/(account|channel)\/[a-zA-Z0-9_\-]+$/;Modify src/utils/env.ts:
Add entries to the environment-to-config mapping (the file uses a plain object, not a named export — look for the existing Discord/Misskey entries near the top):
{PLATFORM}_TOKEN: "platforms.{platform}.token",
{PLATFORM}_ENABLED: "platforms.{platform}.enabled",
// If supporting spontaneous posts:
{PLATFORM}_SPONTANEOUS_ENABLED: "platforms.{platform}.spontaneousPost.enabled",
{PLATFORM}_SPONTANEOUS_MIN_INTERVAL_MS: "platforms.{platform}.spontaneousPost.minIntervalMs",
{PLATFORM}_SPONTANEOUS_MAX_INTERVAL_MS: "platforms.{platform}.spontaneousPost.maxIntervalMs",
{PLATFORM}_SPONTANEOUS_CONTEXT_FETCH_PROBABILITY: "platforms.{platform}.spontaneousPost.contextFetchProbability",Update .env.example: Add example environment variables.
Update helm/values.yaml: Add entries in the env: section.
These changes must be kept in sync:
Add the platform name to the Platform type union and the VALID_PLATFORMS array:
export type Platform = "discord" | "misskey" | "{platform}";
export const VALID_PLATFORMS: readonly Platform[] = ["discord", "misskey", "{platform}"] as const;
isValidPlatform()usesVALID_PLATFORMSinternally and does not need modification.
- Import the new whitelist pattern:
import { {PLATFORM}_WHITELIST_PATTERN } from "../platforms/{platform}/{platform}-config.ts";- Add it to the
isValidChannelId()function:
function isValidChannelId(id: string): boolean {
return DISCORD_WHITELIST_PATTERN.test(id)
|| MISSKEY_WHITELIST_PATTERN.test(id)
|| {PLATFORM}_WHITELIST_PATTERN.test(id);
}The
for (const platformName of VALID_PLATFORMS)loop in config validation will automatically pick up the new platform for spontaneous post config validation.
Modify src/bootstrap.ts:
import { {Platform}Adapter } from "@platforms/{platform}/index.ts";
// After existing adapter registrations:
if (config.platforms.{platform}.enabled) {
logger.info("Registering {Platform} adapter");
const {platform}Adapter = new {Platform}Adapter(config.platforms.{platform});
platformRegistry.register({platform}Adapter);
agentCore.registerPlatform({platform}Adapter);
}Convert platform-specific messages into NormalizedEvent (for incoming triggers) and PlatformMessage (for history). Key field mappings:
| Field | Type | Notes |
|---|---|---|
platform |
Platform |
Your platform name constant |
channelId |
string |
Use prefix conventions if needed (e.g., Misskey: note:{id}, dm:{id}, chat:{id}, timeline:self) |
userId |
string |
Sender's platform user ID |
messageId |
string |
Platform message ID |
isDm |
boolean |
Whether this is a direct/private message |
guildId |
string |
Server/guild ID; empty string if not applicable |
content |
string |
Message text content |
timestamp |
Date |
Must be a Date object |
attachments |
Attachment[] |
Optional; set isImage flag based on MIME type |
Emit converted events via this.emitEvent(normalizedEvent) from within your adapter.
If the platform should support spontaneous posting:
- Add
spontaneousPostfields to the platform config interface. - Implement
determineSpontaneousTarget()to select a channel/user to post to. - For channel lurk reply support (Discord-only feature), see
src/core/channel-lurk-scheduler.ts.
- Place unit tests in
tests/platforms/{platform}/. - Utility functions (
{platform}-utils.ts) can be tested directly without mocks. - Adapter methods require mocking the underlying API client. Reference patterns:
tests/platforms/discord/discord-adapter.test.ts—createMockDiscordAdapter()andmockClientRequest()tests/platforms/misskey/misskey-adapter.test.ts
- Global mock adapter:
tests/mocks/mock-platform-adapter.ts— update if new abstract methods are added. - Run tests with
deno task test.
-
src/platforms/{name}/directory created with adapter, config, utils, and index files - All
PlatformAdapterabstract methods and properties implemented -
PlatformCapabilitiescorrectly configured -
src/types/config.ts—{Platform}Configinterface andPlatformsConfigfield added -
src/types/events.ts—Platformtype andVALID_PLATFORMSupdated -
src/platforms/{name}/{name}-config.ts—{PLATFORM}_WHITELIST_PATTERNdefined -
src/core/config-loader.ts—isValidChannelId()updated -
src/utils/env.ts— environment variable mappings added -
src/bootstrap.ts— conditional adapter registration added -
config.example.yaml— example configuration added -
.env.example— updated -
helm/values.yaml— updated -
tests/platforms/{name}/— unit tests created -
tests/mocks/mock-platform-adapter.ts— updated if new abstract methods exist -
deno fmt --check src/ tests/passes -
deno lint src/ tests/passes -
deno check src/main.tspasses -
deno task testpasses
src/platforms/platform-adapter.ts—PlatformAdapterbase classsrc/platforms/discord/— Discord reference implementationsrc/platforms/misskey/— Misskey reference implementationsrc/types/events.ts—Platformtype,VALID_PLATFORMS,isValidPlatform()src/types/platform.ts—PlatformCapabilities,ReplyResult, etc.src/types/config.ts— Configuration type definitionssrc/core/config-loader.ts—isValidChannelId(), config validationsrc/utils/env.ts— Environment variable to config mappingssrc/bootstrap.ts— Adapter registrationAGENTS.md— Project architecture overview