Skip to main content
Version: v1

Channel plugins

A channel plugin connects a messaging app to ActonOS. The host calls your adapter to send (text, typing, reactions, files) and poll (or you stream with ctx.WS()).

acton-plugin new my-chat --type=channel

Official adapters (Telegram, Discord, Slack, WhatsApp, Zalo) are documented in Official plugins. Copy their envelope β€” do not invent a parallel message format.


Adapter contract​

Implement sdk.ChannelAdapter (or embed sdk.BaseChannel):

type ChannelAdapter interface {
Name() string
DisplayName() string
RequiresPairing() bool
SendMessage(ctx sdk.Context, msg sdk.OutboundMessage) error
PollMessages(ctx sdk.Context) ([]sdk.InboundMessage, error)
}

Register once in init:

func init() {
ch := &MyChannel{
BaseChannel: sdk.BaseChannel{
ChannelName: "mychat",
ChannelDisplayName: "My Chat",
PairingRequired: true,
},
}
sdk.RegisterChannel(ch)
}

Account schema​

Root config_schema is { poll_interval_seconds, accounts[] }. Each account follows spec/CHANNEL_ACCOUNT_SCHEMA.json:

FieldRequiredNotes
account_idyes^[a-z0-9_-]+$
display_nameUI label
credentialyesPlatform-specific (bot_token, or WhatsApp access_token + phone_number_id)
default_agentyesx-ui-widget: agent-selector
listen_targetOptional conversation filter (listen_channel_id is an alias)
enable_typing_indicatordefault true
enable_ack_reactiondefault true
enable_reply_quotedefault true
ack_reaction_emojidefault πŸ‘€

Embed sdk.ChannelAccount in your account struct. A legacy root-level token is still read as account_id=default.


Inbound and outbound envelope​

Inbound (sdk.InboundMessage): kind, message_id, chat_id, thread_id, timestamp, reaction, plus sender fields.

Outbound (sdk.OutboundMessage): kind (text | typing | reaction | media), chat_id, reply_to_id, thread_id, reaction, action, typing, file_name, mime_type, file_data.

Host intentHow it arrivesTypical mapping
Typingkind=typing or typing=true or empty contentDiscord POST /typing, Telegram/Zalo sendChatAction, WhatsApp typing indicator, Slack no-op
Ack / reactkind=reaction + reaction + reply_to_idPlatform reaction APIs
Quote replyreply_to_id when quote is enabledDiscord message_reference, Telegram reply_to_message_id, Slack thread_ts
Filefile_name + file_data (raw bytes; JSON is base64)See Sending files

Helpers:

  • sdk.NewInboundMessage(channel, account, senderID, senderName, content) β€” also extracts @agent mentions
  • sdk.ApplyInboundEnvelope(&msg, chatID, messageID, threadID, timestamp)
  • msg.WantsTyping(), msg.IsTypingOnly(), msg.AttachedFile()
  • sdk.NewOutboundFile(...)
func (c *MyChannel) PollMessages(ctx sdk.Context) ([]sdk.InboundMessage, error) {
msg := sdk.NewInboundMessage("mychat", "bot_primary", "123456", "Alice", "@coder please fix this bug")
sdk.ApplyInboundEnvelope(&msg, "888", "42", "", "")
return []sdk.InboundMessage{msg}, nil
}

Send path​

func (c *MyChannel) SendMessage(ctx sdk.Context, msg sdk.OutboundMessage) error {
token, err := ctx.Vault().GetSecret("channel_token")
if err != nil {
return err
}

if msg.WantsTyping() {
// POST typing to the platform API
if msg.IsTypingOnly() {
return nil
}
}

if name, _, data, ok := msg.AttachedFile(); ok {
contentType, body, err := sdk.EncodeMultipart(map[string]string{
"to": sdk.FirstNonEmpty(msg.ChatID, msg.Recipient),
}, "file", name, data)
if err != nil {
return err
}
_, err = ctx.HTTP().PostBinary("https://api.mychat.com/upload", contentType, body)
return err
}

_, err = ctx.HTTP().PostJSONWithBearer("https://api.mychat.com/send", token, map[string]any{
"to": sdk.FirstNonEmpty(msg.ChatID, msg.Recipient),
"text": msg.Content,
})
return err
}

Read config with ctx.Config().Bind(&cfg). Vault keys for multi-account bots look like discord_bot_tokens. + acc.AccountID.


Sending files​

When a user says β€œsend report.pdf to Telegram”, ActonOS does not upload the file. The host loads it from Workspace and passes raw bytes to acton_channel_send. Your plugin must talk to the chat app.

sdk.OutboundMessage fields:

FieldMeaning
FileNameOriginal name (report.pdf)
MIMETypeContent type
FileDataRaw bytes (base64 on the JSON wire)
ContentOptional caption
Kindmedia when a file is attached

Use msg.AttachedFile() β†’ (name, mime, data).

Do not put PDF bytes in a JSON string

json.Marshal on invalid UTF-8 replaces bytes with U+FFFD. Recipients then see broken fonts in the downloaded PDF. Use sdk.EncodeMultipart + ctx.HTTP().PostBinary (sends body_base64 to the host).

PlatformUpload style
Telegram, Discord, Slack, WhatsAppMultipart + PostBinary
Zalo Bot APIJSON over HTTPS. The file field is a public HTTPS URL (or a small data: URI). Multipart bodies are ignored. Treat HTTP 200 with "ok": false as failure.

If the plugin reports success but the user never sees the file, you are likely using the wrong API β€” or swallowing ok: false.


WebSocket channels​

Discord Gateway and similar APIs use ctx.WS():

conn, err := ctx.WS().Dial("wss://gateway.discord.gg/?v=10&encoding=json", nil)
if err != nil {
return err
}
defer conn.Close()
_ = conn.SendJSON(map[string]any{"op": 1})
payload, ok, err := conn.Poll()

Whitelist both REST and Gateway hosts in net_outbound.