Interfaces
Interfaces
kern supports multiple interfaces simultaneously. Every interface feeds into the same session with consistent metadata.
Message metadata
All messages include context metadata prepended to the text:
[via <interface>, <channel>, user: <id>, time: <iso8601>]
Examples from human interfaces:
[via telegram, telegram:12345, user: 8105113489, time: 2026-04-06T14:30:00-07:00]
[via slack, #engineering, user: U04ABC, time: 2026-04-06T14:30:00-07:00]
[via matrix, matrix:!abc:example.com, user: @oguz:example.com, time: 2026-04-16T21:00:00-07:00]
[via nostr, nostr:npub1n03…p2sku, user: npub1n03…p2sku, time: 2026-09-06T18:30:00-07:00]
[via irc, irc:irc.example.com/#homelab, user: irc:irc.example.com/oguz, time: 2026-09-07T13:05:00-07:00]
[via web, web, user: tui, time: 2026-04-06T14:30:00-07:00]
[via tui, tui, user: tui, time: 2026-04-06T14:30:00-07:00]
Example from a system-generated message (heartbeat):
[via system, heartbeat, user: system, time: 2026-04-20T14:30:00-07:00]
The time: field is ISO 8601 in the host's local timezone with UTC offset. Override with the timezone config field (see config). Storage (logs, recall, session metadata) stays UTC regardless.
The agent sees who's talking, from which channel, and when — and adapts behavior accordingly via instructions in KERN.md.
Interface reference
interface |
Typical channel |
Source | Origin |
|---|---|---|---|
telegram |
telegram:<chatId> |
src/interfaces/telegram.ts |
Telegram user |
slack |
#channel-name or slack-dm:<userId> |
src/interfaces/slack.ts |
Slack user |
matrix |
matrix:<roomId> |
src/interfaces/matrix.ts |
Matrix user |
nostr |
nostr:<npub> |
src/interfaces/nostr.ts |
Nostr DM sender |
irc |
irc:<host>/<target> |
src/interfaces/irc.ts |
IRC user (DM or channel) |
cli |
cli |
src/interfaces/cli.ts |
CLI invocation |
web |
web |
HTTP POST to /message |
Web UI user |
tui |
tui |
HTTP POST to /message |
TUI client |
system |
heartbeat |
src/app.ts runtime timer |
Heartbeat injection |
Asynchronous completions that belong to a conversation — background job results from bash({ background: true }) and sub-agent results from spawn — reuse the origin's envelope rather than a synthetic one, so the queue treats them as messages from that conversation and the reply goes back to the same chat. The body carries the source, e.g. [job:job_a1b2c3d4 exited 0, 42s] npm test or [subagent:sa_a1b2c3d4 done, 42s]. See tools and sub-agents.
The set is extensible — plugins and future interfaces can introduce new values. The envelope format is the stable contract; specific interface/channel values depend on what's loaded at runtime.
Metadata contract
The same metadata flows through three parallel surfaces. Every interface populates them, and every client consumes them.
Surface 1 — Agent-facing text prefix
The text prefix described above ([via <interface>, <channel>, user: <id>, time: <iso8601>]) is prepended to every message before the model sees it. Built in src/app.ts from the internal message object fields. See the Message metadata section for examples.
Surface 2 — Internal message object
Messages enter the runtime via two paths:
Adapter interfaces (src/interfaces/telegram.ts, slack.ts, matrix.ts, cli.ts) construct an IncomingMessage (defined in src/interfaces/types.ts) and pass it to their onMessage callback:
| Field | Type | Required | Description |
|---|---|---|---|
text |
string |
yes | Message body |
userId |
string |
yes | Sender identifier (platform-specific) |
chatId |
string |
yes | Conversation/room identifier (platform-specific) |
interface |
string |
yes | Interface name — for example telegram, slack, matrix, cli |
channel |
string |
no | Human-readable channel label used in the text prefix and SSE events |
attachments |
Attachment[] |
no | Media files attached to the message |
HTTP clients (web UI, TUI) POST to the agent's /message endpoint with a flat JSON payload. The server maps fields directly into the runtime; there is no chatId on this path.
| Field | Type | Required | Description |
|---|---|---|---|
text |
string |
yes (unless attachments) |
Message body |
userId |
string |
no (defaults to "tui") |
Sender identifier |
interface |
string |
no (defaults to "tui") |
Interface name, typically "web" or "tui" |
channel |
string |
no (defaults to "tui") |
Channel label |
attachments |
Attachment[] |
no | Base64-encoded attachments |
connectionId |
string |
no | SSE connection ID to exclude from the echo broadcast |
The runtime itself also synthesizes messages for internal injections: interface: "system" for heartbeats (in src/app.ts). Background job and sub-agent completions are not synthetic — they reuse the envelope of the turn that started them (see the interface reference above).
Surface 3 — SSE broadcast events
When a message arrives, the server broadcasts an SSE event to all connected clients (web UI, TUI, other tabs). Two event types carry metadata:
incoming — a message received from any interface:
| Field | Type | Description |
|---|---|---|
type |
"incoming" |
Event discriminator |
text |
string |
Message body |
fromInterface |
string? |
Source interface name (for example telegram, slack, matrix, web, tui, cli) |
fromUserId |
string? |
Sender identifier |
fromChannel |
string? |
Channel label |
media |
MediaItem[]? |
Attached media (images/files as data URLs) |
outgoing — a message sent by the agent to an external interface via the message tool:
| Field | Type | Description |
|---|---|---|
type |
"outgoing" |
Event discriminator |
text |
string |
Message body |
fromInterface |
string? |
Target interface the message was sent to |
fromUserId |
string? |
Target/recipient user identifier. Despite the from* name, this is the message's destination, not its origin. |
On the client side, the StreamEvent discriminated union in web/lib/types.ts models these SSE payloads. On the server side, src/server.ts defines ServerEvent (extends StreamEvent from src/runtime.ts).
Per-interface population
How each interface populates the internal message fields and SSE events:
| Interface | userId |
chatId |
channel |
fromInterface (SSE) |
|---|---|---|---|---|
| telegram | msg.from.id (stringified) |
msg.chat.id (stringified) |
telegram:<chatId> |
telegram |
| slack | message.user |
message.channel |
#<name> (channel) or slack-dm:<userId> (DM) |
slack |
| matrix | event.sender (mxid) |
roomId |
matrix:<roomId> |
matrix |
| nostr | sender npub |
sender npub |
nostr:<npub> |
nostr |
| irc | irc:<host>/<account> or irc:<host>/~<nick> |
<host>/<nick-or-channel> |
irc:<host>/<nick-or-channel> |
irc |
| cli | "cli" |
"cli" |
"terminal" |
cli |
| tui | "tui" |
— | "tui" |
tui |
| web | "tui" |
— | "web" |
web |
Notes:
- Slack channel names are resolved via
conversations.info— DMs useslack-dm:<userId>(unique per user, so one user's DM is never injected mid-turn into another's), channels use#<channel-name>. - Matrix room IDs are opaque (
!abc:example.com); the channel label prefixes them withmatrix:. - Nostr DMs have no room concept — a conversation is the counterparty, so
userIdandchatIdare both the sender'snpub. - IRC nicks are not identities — anyone can claim one. The
userIdis keyed on the server-verified account from the IRCv3account-tag; an unauthenticated sender gets~<nick>instead and is never auto-paired. Both are namespaced by server host so multiple IRC networks can't collide. - TUI and web submit messages over HTTP without a
chatId— they always represent the operator.
TUI
Interactive terminal chat. Connects to a running agent via HTTP/SSE.
kern tui [path]
- Interface:
tui, channel:tui, user:tui - Always the operator (no pairing required)
- Auto-starts agent if not running
- Auto-selects agent if only one registered
Web UI
Browser-based chat via the kern web static file server.
kern web start
- Interface:
web, channel:web, user:tui - Always the operator (no pairing required)
- Connect to agents directly from the sidebar by entering their URL and token
Setup
kern web run # run in foreground (for Docker)
kern web start # start as background daemon
kern web stop # stop daemon
kern web status # check if running
Architecture
kern webserves only static files — no proxy, no auth- Agents bind to
0.0.0.0on sticky ports with their own auth tokens - Connect to agents directly from the sidebar by entering their URL and token
Authentication
kern web does not provide a separate authentication layer. It only serves the static Web UI.
Access control happens at the agent:
-
Agent auth — each agent has its own
KERN_AUTH_TOKENin.kern/.env, auto-generated on first agent start. -
Direct browser connection — when connecting from the Web UI, users enter the agent URL and token in the sidebar. The browser connects to the agent directly.
Adding agents
Agents are added in the sidebar ("Add server" with URL + token). There is no automatic discovery.
Port and host
kern web start --port 8080 --host 0.0.0.0 (both optional; these are the defaults). The same flags apply to kern web run.
Telegram
Long polling bot. Works behind NAT, no public URL needed.
- Interface:
telegram, channel:telegram:<chatId>, user:<telegramUserId>
Setup
- Message @BotFather on Telegram, create a bot, get the token
- Add
TELEGRAM_BOT_TOKEN=...to.kern/.env - Restart the agent
Behavior
- Unpaired users get a pairing code
- Paired users can chat normally
- Responses stream with typing indicator
- Tool calls shown live (⚙), replaced by response
- Markdown converted to Telegram HTML
- Graceful shutdown: polling stops cleanly on SIGTERM
- 409 conflicts auto-retry after 5 seconds
- Telegram Tool: Agents with Telegram active have access to the
telegramtool to view chat details (chat), list chat administrators (admins), check member status (member), pin or unpin messages (pin,unpin), add emoji reactions (react), or execute arbitrary Telegram Bot API methods (raw).
Slack
Socket Mode connection. No public URL needed.
- Interface:
slack, channel:#channel-nameorslack-dm:<userId>, user:<slackUserId>
Setup
- Create a Slack app at https://api.slack.com/apps
- Enable Socket Mode — generates an app-level token (
xapp-...) - Add bot token scopes:
chat:write,channels:read,channels:historygroups:read,groups:historyim:read,im:write,im:history- Optional for full
slacktool capabilities:users:read,reactions:write,reactions:read,pins:read,bookmarks:read
- Install the app to your workspace — get bot token (
xoxb-...) - Subscribe to bot events:
message.channels,message.groups,message.im
- Add tokens to
.kern/.env:SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... - Invite the bot to channels
- Restart the agent
Behavior
- DMs: pairing required. Unpaired users get a code.
- Channels: reads ALL messages, only responds when @mentioned or directly relevant. Returns
NO_REPLYto suppress. - Replies: post directly to channel or DM (no threading).
- Tool: built-in
slacktool allows inspecting channels, reading message history & threads, looking up users, viewing pins/bookmarks, and adding emoji reactions without posting unprovoked messages. - Graceful shutdown: Socket Mode closes cleanly on SIGTERM.
Matrix
Long-polled /sync against a Matrix homeserver (Synapse, Dendrite, Conduit, etc.). Works against public servers or a tailnet-local homeserver.
- Interface:
matrix, channel:matrix:<roomId>, user:<mxid>(e.g.@alice:example.com)
Setup
- Create a user on your Matrix homeserver for the agent (admin
create-account, shared-secret registration, or normal signup if open). - Log in once to grab an access token:
curl -X POST https://matrix.example.com/_matrix/client/v3/login \ -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"myagent"},"password":"..."}' - Add to
.kern/.env:MATRIX_HOMESERVER=https://matrix.example.com MATRIX_USER_ID=@myagent:example.com MATRIX_ACCESS_TOKEN=syt_... - Restart the agent. Invite it to a room from any Matrix client.
Behavior
- Auto-accepts invites to rooms it's invited to
- Sends typing indicators while thinking
- Replies as formatted HTML (
org.matrix.custom.html) with GFM tables, lists, and code blocks - Inbound media attachments (images, audio, video, files) pass directly into the media digest pipeline
- Pairing required everywhere. Unpaired users (in DMs or group rooms) get a pairing code (same flow as Telegram/Slack). The code is sent once per
(user, room)pair to avoid spam. This differs from Slack channels, which accept messages from any workspace member — Matrix rooms can span homeservers and federations, so kern treats every unknown sender as untrusted. - Group room behavior. Once paired, responses follow the
KERN.mdgroup-room rules (mirrors Slack channel behavior).NO_REPLYto stay quiet. - Agents in shared rooms: first-class — two kern agents can DM each other or coexist in a group room. Pairing codes auto-issue; operator approves via CLI.
- Matrix Tool: Agents with Matrix active have access to the
matrixtool to inspect room history (history), send reactions (react), list joined rooms (rooms), create rooms/channels (createRoom), invite users (invite), pin/unpin dashboard widgets (widget), manage room state (state), or execute arbitrary REST requests (raw).
Limitations (MVP)
- No E2E encryption. Rooms with
m.room.encryptionstate are joined but messages are skipped. Create unencrypted rooms for agents (Element: turn off encryption in room create advanced options). - Outbound media. File and image sending from agent to Matrix is not yet supported (text/HTML only).
Nostr
Encrypted direct messages over Nostr relays. No server to run, no account to create — the agent's identity is a keypair, and any Nostr client (Damus, Amethyst, Primal, Coracle, …) can DM it.
- Interface:
nostr, channel:nostr:<npub>, user:<npub>
Setup
- Generate a keypair for the agent. Any Nostr client can do this, or:
node -e 'const t=require("nostr-tools");const sk=t.generateSecretKey();console.log(t.nip19.nsecEncode(sk));console.log(t.nip19.npubEncode(t.getPublicKey(sk)))' - Add the secret key to
.kern/.env:NOSTR_NSEC=nsec1... - Optionally pick relays in
.kern/config.json(defaults to a few large public relays):"nostrRelays": ["wss://relay.damus.io", "wss://nos.lol"]NOSTR_RELAYS=wss://a,wss://bin.envoverrides the config list (useful for Docker). - Restart the agent. From your Nostr client, DM the agent's
npub. The first sender is auto-paired as operator; everyone else gets a pairing code.
The agent's npub is logged at startup ([nostr] identity npub1…).
Behavior
- Both DM formats in and out: modern NIP-17 gift-wrapped DMs (kind 1059, NIP-44 encryption, sender and recipient hidden from relays) and legacy NIP-04 (kind 4, every client supports it). Replies go back in whichever format the sender used.
- Multi-relay. Subscribes to every configured relay, dedupes by event id, publishes replies to all connected relays. One reachable relay is enough.
- Reconnects with backoff. Missed DMs are replayed on resubscribe (
sincecursor), so a relay blip doesn't lose messages. - Pairing works like Telegram/Slack DMs — gated per sender
npub. Themessagetool can DM any pairednpubproactively. - Private deployments. Point
nostrRelaysat a single self-hosted relay on your tailnet (e.g. khatru, nostr-rs-relay) and nothing ever touches a public server. Relays don't federate — traffic goes only where you point it.
Limitations (MVP)
- No group channels (NIP-28 kind 42 / NIP-29). DMs only.
- NIP-04 leaks metadata. Kind 4 exposes sender and recipient to relays (not content). Clients that only speak NIP-04 get that tradeoff; NIP-17 senders don't.
- No media, reactions, or typing indicators. Plain text turns only.
- Relay size limits. Public relays cap events around 64–100 KB; very long replies may be rejected by some relays (publish succeeds if any relay accepts).
Discord
Direct integration via Discord Bot API gateway.
Setup
- Create a Discord application at the Discord Developer Portal.
- Create a Bot under the Application settings.
- Under Privileged Gateway Intents, enable Message Content Intent.
- In
.kern/.env:DISCORD_TOKEN=your-discord-bot-token - Invite the bot to your server with permissions to View Channels, Send Messages, and Read Message History.
- Restart the agent.
Behavior
- Direct Messages (DMs): Gated by pairing (same flow as Telegram/Slack). Unpaired users receive a pairing code.
- Guild / Server Channels: Responds when @mentioned (either direct user mention or via bot role). Set
"discordMentionOnly": falsein config orDISCORD_MENTION_ONLY=falsein.envto receive all channel messages. - Message Chunking: Discord's 2,000-character limit is automatically split across clean message boundaries (newlines/spaces).
- Attachments: Supports images, audio, video, and document uploads up to 25 MB.
- Outbound Messaging: Send to Discord users or channels via the
messagetool withinterface: "discord". - Discord Tool: Agents with Discord active have access to the
discordtool to read channel or DM history (history), add reactions (react), inspect pinned messages (pins), fetch user info (user), or execute arbitrary REST requests (raw).
IRC
Plain IRC — any network, or your own server on the tailnet. No dependencies beyond Node's net/tls: raw protocol with IRCv3 capability negotiation.
- Interface:
irc, channel:irc:<host>/<target>, user:irc:<host>/<account>
Setup
- Set a connection URL in
.kern/config.json:"irc": "ircs://myagent@irc.example.com:6697/#homelab"IRC_URL=...in.envoverrides it (useful for Docker). - Restart the agent. It connects, joins the listed channels, and DMs work immediately. The first authenticated sender is auto-paired as operator; everyone else gets a pairing code.
URL anatomy:
ircs://nick:password@host:6697/#chan1,#chan2
│ │ │ │ │ └── channels, comma-separated (# optional)
│ │ │ │ └──────── port (default 6667 plain, 6697 TLS)
│ │ │ └───────────── server host
│ │ └────────────────────── server password, sent as PASS (optional)
│ └─────────────────────────── nick (default "kern")
└────────────────────────────────── irc:// plain, ircs:// TLS
Multiple networks: whitespace-separate whole URLs (commas already separate channels).
"irc": "ircs://myagent@irc.libera.chat:6697/#kern ircs://myagent@irc.internal:6697/#ops"
Behavior
- DMs are gated by pairing, like Telegram/Slack/Nostr. Channels are open.
- Channels deliver all messages. Just like Slack and Matrix rooms, the agent receives every message in configured channels so it maintains context. The agent prompt instructs it to only respond when addressed, mentioned, or when it has something useful to say, and use
NO_REPLYotherwise. A leadingnick:address is stripped before the message reaches the model. - Identity is the account, not the nick. See below.
- Markdown is converted to IRC control codes — bold, italic, monospace. Headers become bold, tables lose their separator rows, code fences are unwrapped, links render as
label <url>. - Lines are wrapped to stay under the 512-byte protocol limit (splitting on word boundaries, never mid-codepoint) and sent about 4/sec so the server doesn't flood-kick. Very long replies are truncated with a notice.
- Reconnects with jittered exponential backoff, up to a minute. Nick collisions retry with underscore suffixes.
/meactions arrive as* nick does something. Other CTCP is ignored.messagetool can address any<host>/<target>— a paired nick or a channel the agent is in.
Identity and spoofing
IRC nicks are not identities. Anyone can /nick oguz the moment you disconnect. So the sender is keyed on the server-verified account from the IRCv3 account-tag, never on the nick:
| Sender | userId | Auto-pairs? |
|---|---|---|
| Logged in to a server account | irc:<host>/<account> |
Yes, if first user |
| Not logged in | irc:<host>/~<nick> |
Never |
Unauthenticated senders always go through a pairing code, and the tilde is visible to the agent so it knows the name is unverified. Both forms are namespaced by server host, so the same account name on two networks stays two different users.
This depends on the server supporting account-tag (Ergo, Solanum/Libera, InspIRCd, UnrealIRCd all do) and the user actually being logged in. On a server without it, every sender is unauthenticated and nothing auto-pairs.
Limitations
- No SASL. The agent authenticates with a server
PASSif given one; it can't log in to a NickServ account. This affects the agent's own identity, not its ability to verify senders. - No media. Text only.
- No history replay. Messages sent while the agent is disconnected are lost — IRC has no store-and-forward (barring server-side history extensions, which aren't used).
- No streaming. Replies arrive as complete messages, not token by token.