A high-performance, stateless Matrix Adapter service written in Go, implementing the Alkemio Matrix Adapter specification.
This repository contains two core elements:
- Matrix Adapter Service: The Go application that provides the runtime service.
- Matrix Adapter Library: A TypeScript library (
lib/) providing shared payloads and event types for clients of the service.
- Hexagonal Architecture: Clean separation of core domain, ports, and adapters.
- Stateless Design: Designed for horizontal scalability.
- Matrix AppService: Connects to Matrix Homeserver as an Application Service.
- RabbitMQ Integration: Consumes commands and publishes events via AMQP.
- Type-Safe DTOs: Shared contract definitions with TypeScript generation.
- Actor ID Support: Native support for Alkemio Actor IDs (UUIDs) mapped to Matrix IDs.
- Go 1.25+
- Docker & Docker Compose
- Matrix Homeserver (Synapse/Dendrite)
- RabbitMQ
- Node.js 22+ / pnpm 9+ (for Library)
The service is configured via environment variables or a config.yaml file. The environment variables are compatible with the legacy TypeScript service.
| Variable | Description | Default |
|---|---|---|
ENVIRONMENT |
Environment (development, production) | development |
LOGGING_LEVEL_CONSOLE |
Logging level (debug, info, warn, error) | info |
SYNAPSE_SERVER_URL |
URL of the Matrix Homeserver | - |
SYNAPSE_HOMESERVER_NAME |
Matrix Homeserver Name | - |
MATRIX_AS_TOKEN |
AppService Token (as_token) | - |
MATRIX_HS_TOKEN |
Homeserver Token (hs_token) | - |
MATRIX_BOT_ACTOR_ID |
Bot Actor ID (UUID), also used as Matrix localpart | 00000000-0000-0000-0000-000000000000 |
MATRIX_BOT_DISPLAY_NAME |
Display name for the bot user in Matrix | Alkemio |
SYNAPSE_SERVER_SHARED_SECRET |
Synapse registration_shared_secret for auto-promoting bot to server admin |
- |
FILE_SERVICE_MAX_ATTACHMENT_BYTES |
Per-attachment byte cap for outbound media. Must parse as an integer — a malformed value fails the boot. A non-positive value falls back to the default. Transfers must also complete within the operation budget described under Media Attachments | 52428800 (50 MiB) |
RABBITMQ_URL |
Full AMQP Connection URL | - |
RABBITMQ_HOST |
RabbitMQ Host (if URL not set) | - |
RABBITMQ_PORT |
RabbitMQ Port (if URL not set) | 5672 |
RABBITMQ_USER |
RabbitMQ User (if URL not set) | - |
RABBITMQ_PASSWORD |
RabbitMQ Password (if URL not set) | - |
-
Install Dependencies:
make deps
-
Run the Service:
make run
make buildcd lib
pnpm install
pnpm buildmake docker-builddocker-compose up --build- Linting:
make lint - Testing:
make test - Test Coverage:
make test-coverage - Format:
make fmt - Generate DTOs:
make generate(Updates Go DTOs and generates TypeScript definitions inlib/)
The shared TypeScript library (lib/) is automatically published via GitHub Actions workflows.
| Trigger | Registry | Package Name | Version | Tag |
|---|---|---|---|---|
Git tag (v*) |
npmjs | @alkemio/matrix-adapter-lib |
From tag (e.g., v1.2.3 → 1.2.3) |
latest |
| Pull Request | pkg.pr.new | @alkemio/matrix-adapter-lib |
Commit-based preview URL | — |
| Manual dispatch | pkg.pr.new | @alkemio/matrix-adapter-lib |
Commit-based preview URL | — |
-
Development: PRs that modify
lib/orpkg/dto/automatically publish preview versions via pkg.pr.new for testing. -
Production Release: Create a git tag to publish to npmjs:
git tag v1.2.3 git push origin v1.2.3
-
Install from npmjs (production):
npm install @alkemio/matrix-adapter-lib
-
Install preview (development/testing):
npm install https://pkg.pr.new/alkem-io/matrix-adapter/@alkemio/matrix-adapter-lib@{commit-sha}
- npmjs: Uses OIDC trusted publishing (no token required) with provenance attestation.
- pkg.pr.new: No authentication required — uses the pkg.pr.new GitHub App.
The project follows a Hexagonal (Ports & Adapters) architecture:
cmd/: Entrypoints (main application wiring).internal/app/: Application lifecycle, dependency injection, and startup logic.internal/config/: Configuration loading and validation.internal/core/domain: Pure business logic and entities.internal/core/ports: Interfaces defining interactions with the outside world.internal/core/service: Implementation of business use cases.internal/infrastructure: Adapters for external systems (Matrix, RabbitMQ, HTTP).pkg/dto: Data Transfer Objects shared with external consumers (source fortygogeneration).lib/: Shared TypeScript library containing generated DTOs and event types.
The Matrix Adapter uses Actor IDs (UUIDs) to identify users in external APIs.
- Actor ID: The Alkemio agent ID (
agent.id) - used in all RabbitMQ commands and events - Matrix ID: The Matrix user identifier (e.g.,
@uuid:server)
Actor IDs are used directly as Matrix localparts (@actor-uuid:server).
All external APIs (RabbitMQ commands and events) always use Actor IDs.
The Matrix Adapter implements a structured RabbitMQ protocol for communication with the Alkemio Server. See MatrixAdapterProtocol_V3.md for the full specification.
| Category | Command | Topic |
|---|---|---|
| Room | Create Room | communication.room.create |
| Get Room | communication.room.get |
|
| Update Room | communication.room.update |
|
| Delete Room | communication.room.delete |
|
| List Rooms | communication.room.list |
|
| Get Room Members | communication.room.members.get |
|
| Batch Add Member | communication.room.member.batch.add |
|
| Batch Remove Member | communication.room.member.batch.remove |
|
| Space | Create Space | communication.space.create |
| Get Space | communication.space.get |
|
| Update Space | communication.space.update |
|
| Delete Space | communication.space.delete |
|
| List Spaces | communication.space.list |
|
| Batch Add Member | communication.space.member.batch.add |
|
| Batch Remove Member | communication.space.member.batch.remove |
|
| Hierarchy | Set Parent | communication.hierarchy.set_parent |
| Message | Send Message | communication.message.send |
| Get Message | communication.message.get |
|
| Delete Message | communication.message.delete |
|
| Reaction | Add Reaction | communication.reaction.add |
| Remove Reaction | communication.reaction.remove |
|
| Get Reaction | communication.reaction.get |
|
| Thread | Get Thread Messages | communication.thread.messages.get |
| Actor | Sync Actor Profile | communication.actor.sync |
| Read Receipts | Mark Message Read | communication.message.read |
| Get Unread Counts | communication.room.unread_counts.get |
|
| Custom State | Set Room State | communication.room.state.set |
| Get Room State | communication.room.state.get |
|
| Set Space State | communication.space.state.set |
|
| Get Space State | communication.space.state.get |
| Event | Topic | Description |
|---|---|---|
| Message Received | communication.message.received |
Emitted when a message is received in a room |
| DM Requested | communication.room.dm.requested |
Emitted when a DM room creation is requested via webhook |
| Reaction Added | communication.reaction.added |
Emitted when a user adds a reaction to a message |
| Reaction Removed | communication.reaction.removed |
Emitted when a user removes a reaction from a message |
| Room Member Left | communication.room.member.left |
Emitted when a user leaves or is kicked from a room |
| Read Receipt Updated | communication.room.receipt.updated |
Emitted when a user's read position is updated |
| Message Edited | communication.message.edited |
Emitted when a message is edited |
| Message Redacted | communication.message.redacted |
Emitted when a message is deleted/redacted |
| Room Created | communication.room.created |
Emitted when a room is created in Matrix |
| Room Member Updated | communication.room.member.updated |
Emitted when a user's membership status changes (join, invite, etc.) |
| Room Updated | communication.room.updated |
Emitted when a room's name, avatar, or topic changes |
| Space Updated | communication.space.updated |
Emitted when a space's name, avatar, or topic changes |
communication.message.send publishes non-empty content, one
existing_media reference, or both. Media sends contain a local media_id, display name
and actual MIME/size/dimensions; they publish as the sender without transferring
bytes again or emitting a document-ID hint. When content accompanies the media
it becomes the Matrix media caption: body carries the caption and filename
the display name. Without a caption, body is the display name and filename
is omitted.
Browsers POST raw original bytes to
/api/private/rest/messaging/media/upload?filename=.... The gateway strips the
/api/private prefix and client identity headers, resolves the authenticated actor,
and forwards to /rest/messaging/media/upload. The adapter accepts only the
trusted X-Alkemio-Actor-Id UUID; absent, anonymous and guest identities are rejected.
Uploads are room-neutral. Sending and placement apply current destination checks.
Every upload needs an exact Content-Length within the existing attachment-size
cap. A body that is not an image streams directly to Synapse; the adapter neither
accumulates nor spools it. A JPEG, PNG or WebP body, recognised from its bytes
rather than its declared type, is read whole into memory and stored without its
location, device and time metadata, with the content type and length of the
rewritten bytes; of its Exif data only the orientation tag is kept. A corrupt
image is refused with 400 invalid_media. Images share a fixed memory budget, and
an image that cannot get its share within a short wait is refused with 503 upload_busy and a Retry-After header. An image body that stalls is cut off with
408 upload_timeout. Zero bytes use the SDK empty-body form. Failed uploads and
uncertain sends are not replayed automatically. timeout_ms bounds message
publication below the caller RPC wait; it defaults to 25 seconds.
Incoming events and history expose local media references plus event metadata.
Old io.alkemio.document_id hints are ignored. The server handles shared bucket
placement; the adapter does not move or delete stored files. Redacting an event
does not delete shared media bytes.
The read-only operator command exports retained local-media events for an explicit Alkemio room inventory. Run the built adapter with its normal Matrix connection configuration and existing administrator appservice credentials:
matrix-adapter attachments-export --rooms room-list.json --output events.jsonlroom-list.json is an object with schemaVersion: 1, scope set to fixture or
supported-room-inventory, and roomIds containing Alkemio room UUIDs. The command
resolves their canonical aliases, verifies the observed alias mapping, and scans
all history pages including thread replies. It does not start the service, join
rooms, register users, reconcile rooms, or access a database. A room whose alias
Synapse reports as not found was never created in Matrix: it is recorded as
room_absent with no history and counts as covered. Other alias failures and
history failures are recorded and make the command exit unsuccessfully.
The command exclusively creates events.jsonl and events.jsonl.coverage.json
with mode 0600; existing output files are never overwritten. Events contain only
room/event/media identifiers, actor ID, sanitized filename, optional thread root,
and timestamp. Document hints are not exported. Tokens, message text and content hashes are not
exported. The sidecar records requested/resolved rooms, mapping and pagination
completion, counts, and safe failure or skipped-event codes. Redacted, encrypted
or unmappable retained events remain explicit limitations even when pagination
completes.
complete: true certifies the requested scan, not a complete production inventory
or permission to remove staging files. The server normalization tool must compare
the exact current supported-room inventory and reject unsafe skipped markers.
Keep a controlled operator window without concurrent historical changes between
inventory and apply. Production normalization is a separate operator action;
running this exporter never changes attachment storage.
The adapter supports controlled DM (Direct Message) room creation through a webhook-based flow. This allows Synapse to request approval from the Alkemio Server before creating DM rooms.
┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌───────────┐
│ Synapse │ 1. │ Adapter │ 2. │ Alkemio Server │ 3. │ Adapter │
│ Spam │───────▶│ Webhook │───────▶│ (via RabbitMQ)│───────▶│ (via │
│ Checker │ POST │ Handler │ Publish│ │ Command│ RabbitMQ)│
└─────────┘ └──────────────┘ └─────────────────┘ └───────────┘
│
4. Create DM Room │
(type: "direct") ▼
┌───────────┐
│ Matrix │
│Homeserver │
└───────────┘
- Synapse Spam Checker calls the adapter's webhook when a user attempts to create a DM
- Adapter publishes
DMRequestedEventtocommunication.room.dm.requestedtopic - Alkemio Server processes the request and sends
communication.room.createwithtype: "direct" - Adapter creates the DM room in Matrix using existing room creation logic
| Method | Path | Auth |
|---|---|---|
| POST | /_matrix/app/alkemio/dm-request |
Bearer token (HS token) |
{
"inviter": "@550e8400-e29b-41d4-a716-446655440001:matrix.alkemio.org",
"invitee": "@660e8400-e29b-41d4-a716-446655440002:matrix.alkemio.org"
}Success (202 Accepted):
{"status": "accepted"}Error responses use standard HTTP error codes with JSON error messages:
401 Unauthorized: Invalid or missing Bearer token400 Bad Request: Invalid payload or missing fields500 Internal Server Error: Failed to publish event
The Alkemio Server creates DM rooms using the existing communication.room.create command with type: "direct":
{
"alkemio_room_id": "new-uuid-for-dm-room",
"type": "direct",
"name": "DM: User A - User B",
"initial_members": ["actor-uuid-1", "actor-uuid-2"],
"join_rule": "invite"
}All responses follow a standard envelope:
{
"success": true,
"error": null
}Error responses include structured error information:
{
"success": false,
"error": {
"code": "ROOM_NOT_FOUND",
"message": "Room with ID xyz does not exist",
"details": "Optional technical details"
}
}| Code | Description |
|---|---|
INVALID_PARAM |
Request validation failed (invalid payload or parameters) |
ROOM_NOT_FOUND |
Referenced room does not exist |
SPACE_NOT_FOUND |
Referenced space does not exist |
ACTOR_NOT_FOUND |
Referenced actor does not exist |
MATRIX_ERROR |
Matrix SDK/homeserver error |
INTERNAL_ERROR |
Unexpected system error |
NOT_ALLOWED |
Operation not permitted |
The adapter's bot user (@00000000-...:domain) operates as a Synapse server admin but is not a member of rooms (only spaces). This architecture minimizes the bot's footprint in user-visible room lists.
- Spaces: Bot stays as a member (required for hierarchy operations)
- Rooms: Bot leaves after creation; re-joins temporarily only if no ghost users are available for writes
- Reads: All room reads (state, messages, members) use the Synapse Admin API — no membership required
- Writes: State events are sent via ghost users (the member with the highest power level)
- Invites: Ghost users are joined directly via
EnsureJoined— no invite events sent to clients
On startup, the adapter ensures the bot is a Synapse server admin:
- Already admin → proceeds normally
SYNAPSE_SERVER_SHARED_SECRETset → registers bot as admin via shared secret, or creates a temporary admin to promote the bot- No secret → logs a warning with SQL instructions
The adapter supports setting/reading custom state events with the io.alkemio. prefix on any room or space. These are used for:
io.alkemio.visibility: Controls whether a room appears in Element via the Synapse module- Custom metadata for rooms/spaces managed by the Alkemio platform
Custom state can be set at room creation (via custom_state field) or independently via the state.set/get API endpoints.
The following Synapse homeserver.yaml settings are required:
# Allow bot to publish rooms to the public directory
room_list_publication_rules:
- user_id: "@00000000-0000-0000-0000-000000000000:your.domain"
action: allow
- action: deny
# Optional: Enable admin bootstrap (alternative to manual DB setup)
registration_shared_secret: "your-secret"The adapter includes a companion Synapse module (alkemio_room_control.py) that:
- Controls room creation — only the AppService bot can create rooms
- Filters
/syncresponses — hides rooms withio.alkemio.visibility: {visible: false}from Element - DM webhook — notifies the adapter when users attempt to create DMs from Element
The adapter requires an AppService registration file on Synapse. The bot uses MATRIX_BOT_ACTOR_ID as its Matrix localpart, following the same UUID pattern as all other actors.
id: alkemio-matrix-adapter
url: "http://matrix-adapter:8280"
as_token: <your-as-token>
hs_token: <your-hs-token>
sender_localpart: "00000000-0000-0000-0000-000000000000" # Must match MATRIX_BOT_ACTOR_ID
namespaces:
users:
# Bot user - exclusive (security: prevents impersonation)
- exclusive: true
regex: "@00000000-0000-0000-0000-000000000000:.*"
# Regular UUID users - NOT exclusive so they can login via OIDC/Element
- exclusive: false
regex: "@[0-9a-fA-F-]{36}:.*"
aliases:
- exclusive: true
regex: "#[0-9a-fA-F-]{36}:.*" # Room aliases
rate_limited: falseImportant:
- The
sender_localpartmust match theMATRIX_BOT_ACTOR_IDenvironment variable - The bot user namespace should be
exclusive: trueto prevent impersonation attacks