Skip to content

Cryptid Messages

CryptidMessage is the application-level envelope for all encrypted communication within Cryptid groups. All messages are encrypted via MLS and signed by the sender’s device. This document specifies the message structure and wire format only.

CryptidMessage uses a hierarchical structure that distinguishes freestanding messages from operations on existing messages:

These are complete messages that don’t reference other content:

  • Message: Text or sticker content
  • TypingIndicator: Transient composition state
  • ReadReceipts: Batch notification of read messages
  • PersonaUpdate: Notify group of persona changes
  • Custom: Extension point for future use

These always reference existing content via message ID or file ID:

  • MessageAction: Targets a text message (via MessageId)

    • Reaction: Add/remove emoji reaction
    • Edit: Modify a past message
    • AttachFile: Associate a file with a message
    • MarkDeleted: Request deletion of a message
  • FileAction: Targets a file (via FileId)

    • Request: Ask for file data (with optional byte range for resumable downloads)
    • Data: Send file data chunk in response to Request
    • MarkDeleted: Notify that file is no longer available

/// Type-safe wrapper around a UUIDv7 identifying a message
pub struct MessageId(Uuidv7);
/// Application-level message structure (exists inside MLS ciphertext)
/// This is what clients see after MLS decryption. Servers never see this.
struct CryptidMessage {
// A UUIDv7 (incorporates Unix millisecond timestamp)
message_id: MessageId,
// Derived from the Ed25519 public key
sender: DeviceId,
// The persona that was used to send this message
sender_persona_id: Option<PersonaId>,
// ThreadId is a Uuidv4 that represents a message thread
thread_id: Option<ThreadId>,
// Tagged enum for message content or operations
inner: MessageInner,
}
  • message_id

    • MessageId (wrapper around Uuidv7)
    • MessageIds MUST NOT be reused
    • We use UUIDv7 here for improved temporal uniqueness guarantees
    • Timestamps are encoded within the UUIDv7 so clients can extract the millisecond if needed
  • sender

    • Fixed to the MLS member’s device identity
    • Authenticated by MLS group signature
    • Immutable once in the group
  • sender_persona_id

    • Optional u16
    • A value of 0 or null should be treated as the default persona
    • No validation against sender’s Personas
    • Clients are responsible for validating this against the sender’s known personas
    • If missing, clients use sender’s default persona
    • No protocol-level constraints on the format
  • thread_id

    • ThreadId is a uuidv4 that represents a conversation thread
    • It has no semantic meaning to the protocol itself. It purely exists as a way for clients to organize messages
    • There’s no validation. Clients may assign any UUIDv4 value they like
    • Thread nesting/hierarchy is client responsibility
  • inner

    • Tagged enum (MessageInner) determining message semantics
    • Distinguishes between freestanding messages and operations on existing messages

FileRef is a unified structure for identifying files across both message attachments and media extensions:

/// Core file identity shared across attachments and media references
struct FileRef {
size: u64, // File size in bytes
plaintext_hash: [u8; 32], // Blake3 of unencrypted content
file_id: FileId, // Structured file identifier
}
/// Type-safe file identifier
struct FileId {
uploader: DeviceId,
id: u64, // Local counter maintained by uploader
}
enum MessageInner {
// Freestanding messages
Message(String), // Text message (also does Stickers via "<media_pack/id>" encoding)
// Operations on existing messages
MessageAction(MessageId, MessageAction),
// File operations
FileAction(FileId, FileAction),
// Transient notifications
ReadReceipts(Vec<MessageId>),
TypingIndicator { timeout_secs: u8 },
// Persona synchronization
PersonaUpdate {
updated_persona_id: PersonaId,
updated_persona: Persona,
},
// Future extensibility
Custom { custom_type: String, payload: serde_json::Value },
}
enum MessageAction {
// Anyone can send
Reaction { emoji: String, add: bool },
// Sender or mods/admins only
Edit {
new_text: Option<String>,
new_persona_id: Option<PersonaId>,
},
AttachFile {
filename: String,
mime_type: String,
file_ref: FileRef,
alt_text: Option<String>,
},
MarkDeleted,
}
enum FileAction {
Request(Option<Range<u64>>), // None = whole file
Data { start: u64, data: Vec<u8> }, // Plaintext data
MarkDeleted,
}
// Sent as MessageInner::Message(String)
// Example: "Hello, world!"

The Message variant contains plain UTF-8 text. The protocol specifies two special syntaxes for interoperable references:

Format: :emoji_name:

  • Clients MUST recognize and attempt to resolve :emoji_name: to an emoji from the group’s custom_media extension or as an emoji shortcode.
  • If neither are found, clients display the literal string :emoji_name: as fallback
  • Example: “That’s :excited:!” renders as “That’s 😆!” (if emoji exists) or “That’s :excited:!” (if not found)

Format: <media_pack/{pack_id}/{sticker_id}>

  • While stickers are technically sent as text messages, the protocol recognizes this special pattern
  • Clients MUST recognize <media_pack/...> as a sticker reference
  • Clients attempt to resolve {pack_id} and {sticker_id} from the group’s custom_media extension
  • If not found, clients display the literal string as fallback
  • Clients MAY render stickers as inline images, embeds, or other formats

Example: <media_pack/pack-alice-cats/excited-cat>

Beyond emoji and sticker references, clients are responsible for all other text rendering and formatting. This includes:

  • Markdown support (bold, italic, code blocks, etc.)
  • Escape sequences (if clients choose to implement them locally)
  • Link detection and previewing
  • Quote formatting
  • Syntax highlighting
  • Custom parsing rules

Rationale: Different clients have different UX needs. The protocol specifies only what needs to be interoperable (references to shared group resources). Presentation is a client concern.

MessageInner::MessageAction(
MessageId,
MessageAction::Reaction {
emoji: String,
add: bool,
}
)
  • message_id is the target message being reacted to
  • emoji is the reaction emoji
  • add: true adds the reaction, add: false removes it
  • No validation that target message_id exists or is valid
  • No constraints on emoji content (client is responsible)
  • Clients decide how to display, aggregate, or limit reactions
MessageInner::MessageAction(
MessageId,
MessageAction::Edit {
new_text: Option<String>,
new_persona_id: Option<PersonaId>,
}
)
  • message_id is the target message being edited
  • new_text is the updated message content (if changing text)
  • new_persona_id is the updated persona for this message (if changing persona)
  • Validation: At least one field (new_text or new_persona_id) MUST be Some
  • Permission: Only the original message sender (or group admin) may send Edit actions
    • Clients MUST verify that sender in the Edit message matches the sender of the original Message
    • If new_persona_id is provided, it MUST be a valid persona for the sender
    • Servers/groups can enforce this via MLS permissions
  • No protocol-level ordering. Clients handle edit conflicts (last-write-wins or local conflict resolution)
  • No history tracking. Only latest content available
  • Clients decide whether to store and show edit history
MesasgeInner::MessageAction(
MessageId,
MessageAction::MarkDeleted
)
  • message_id is the target message being deleted
  • Permission: Only the original message sender (or group admin/mod) may send MarkDeleted actions
    • Clients MUST verify that sender in the MarkDeleted message matches the sender of the original Message
    • Servers/groups can enforce this via MLS permissions
  • Spec compliant clients WILL delete messages but deletion cannot be enforced on non-compliant clients
  • Clients decide how to handle local deletion, sync, undo, etc.
MessageInner::ReadReceipts(Vec<MessageId>)
  • Contains a list of message IDs that the sender has read
  • No validation of message IDs
  • Purely informational. Clients handle display logic
  • No protocol requirement for reliability or ordering
MessageInner::TypingIndicator { timeout_secs: u8 }
  • Transient notification indicating sender is composing a message
  • timeout_secs tells recipients how long to display the typing indicator (recommended: 3-10 seconds)
  • Has no persistence requirement
  • Clients decide timeout enforcement, display, and when to send updates
MessageInner::PersonaUpdate {
updated_persona_id: PersonaId,
updated_persona: Persona,
}
  • Notifies group members when a user updates their persona
  • updated_persona_id identifies which persona was updated (Default or numbered persona)
  • updated_persona contains the complete updated persona data (display name, profile picture, bio, pronouns)
  • Purpose: Keeps persona information synchronized across all groups without requiring manual refresh
  • Validation:
    • updated_persona_id MUST be PersonaId::Default or a valid PersonaId::Id(NonZeroU16)
    • updated_persona MUST be a valid Persona structure
    • Sender MUST be a member of the group (verified via MLS authentication)
  • Recipients update their local cache of the sender’s personas
  • Not retroactive, only affects future message rendering

When to send:

  • User changes display name, profile picture, bio, or pronouns
  • User updates a numbered persona
  • Broadcast to ALL groups the user is in

When NOT to send:

  • Initial persona creation (no one knows about you yet)
  • Persona deletion (just stop using it)

See Cryptid’s Identity System for persona structure details.

Files are announced via MessageAction::AttachFile. Every file attachment is paired with a Message (which may be empty or contain a caption). An AttachFile action always targets a Message variant.

MessageInner::MessageAction(
MessageId,
MessageAction::AttachFile {
filename: String,
mime_type: String,
file_ref: FileRef,
alt_text: Option<String>,
}
)

Field Specification:

  • MessageId is the ID of the associated Message. Clients MUST first send a Message (with caption text or empty string), then send the AttachFile MessageAction targeting that message ID.
  • filename: No path separators. UTF-8 required. Client sanitizes.
  • mime_type: Hint only. No validation or enforcement.
  • file_ref.size: File size in bytes. Informational. Clients use for progress and storage checks.
  • file_ref.plaintext_hash: Blake3 hash of unencrypted file. Used for deduplication and verification.
  • file_ref.file_id: Structured FileId (uploader + local counter). Type-safe and prevents malformed IDs.
    • uploader: DeviceId of the file’s owner
    • id: Local counter maintained by uploader
  • alt_text: Alternative/descriptive text for the file (for accessibility and reference)

Invariant: File Attachment Protocol

Files are always associated with a message:

  1. File with caption:

    • Send MessageInner::Message("Here's the document")
    • Receive message ID msg-1
    • Send MessageInner::MessageAction(msg-1, MessageAction::AttachFile { ... })
  2. File without caption:

    • Send MessageInner::Message("") (empty string)
    • Receive message ID msg-1
    • Send MessageInner::MessageAction(msg-1, MessageAction::AttachFile { ... })

This invariant simplifies implementation: reactions and edits on files can be applied to the associated message just like any other message. Clients can display the attachment alongside its caption or in a single unified view.

Usage:

When a sender announces a file to the group:

  1. Sends MessageInner::Message (caption or empty string)
  2. Gets back a message ID
  3. Sends MessageAction(MessageId, AttachFile) with file metadata
  4. Recipients see the announcement
  5. Recipients send FileAction(Request) to fetch the file
  6. Sender responds with FileAction(Data) chunks containing plaintext data
  7. MLS encrypts the entire message containing chunks
  8. Recipient verifies plaintext_hash after receiving all chunks
MessageInner::FileAction(
file_id: FileId,
FileAction::Request(Option<Range<u64>>)
)
  • file_id: Matches the FileId from a FileAttachment message (structured with uploader and id)
  • range:
    • None = request entire file (full download)
    • Some([start, end]) = request specific byte range (resumable downloads)
  • Validation: Clients MUST NOT request ranges beyond announced size

Usage:

  • Recipient sends this to request a file from the sender
  • Used for initial download and resuming interrupted transfers
  • Sender SHOULD respond with FileAction(Data) chunk(s)
MessageInner::FileAction(
file_id: FileId,
FileAction::Data {
start: u64,
data: Vec<u8>, // File chunk
}
)
  • file_id: Matches the FileId of the requested file (structured with uploader and id)
  • start: Byte offset where this chunk starts
  • data: file chunk (base64 in JSON format). MLS encrypts the outer message.
  • Validation: Clients verify (start + data.len()) <= announced file size

Usage:

  • Sender responds to FileAction(Request) with one or more FileAction(Data) messages
  • Each message contains a chunk of plaintext file data
  • Enables resumable downloads and memory-efficient transfer
  • If range request was for bytes [1048576..2097152], sender responds with data covering that range
MessageInner::FileAction(
file_id: FileId,
FileAction::MarkDeleted,
)
  • Courtesy indicator that a file has been deleted
  • Indicates the sending device will not respond to further requests for this file
  • Clients SHOULD verify that the device sending the notice owns the corresponding FileId (matches uploader field) before listening to it
  • Permission: Only the file uploader may send MarkDeleted
MessageInner::Custom {
custom_type: String,
payload: serde_json::Value,
}
  • Future extensibility mechanism
  • No protocol validation. Implementation-specific.
  • Clients ignore unknown types.
  • Format: JSON
  • Encoding: UTF-8
  • Optional fields: Omitted if None
  • Binary data (Vec<u8>): Base64 encoded in JSON
  • MessageInner uses tagged enum serialization (discriminator + payload)
  • FileId serialized as: { "uploader": "<device_id>", "id": <u64> }
  • FileRef serialized as: { "size": <u64>, "plaintext_hash": "<hex>", "file_id": {...} }
  • Ranges serialized as: [start, end] or null
  • message_id is a valid UUIDv7
  • sender is a valid DeviceId
  • sender_persona_id (if present) is valid u16
  • thread_id (if present) is a valid UUIDv4
  • Text in Message variant is valid UTF-8
  • filename in AttachFile is valid UTF-8 (no path separators)
  • mime_type is valid UTF-8
  • plaintext_hash in FileRef is a 32-byte array
  • FileId.uploader is a valid DeviceId
  • FileId.id is a valid u64
  • In FileAction::Data: start + data.len() <= announced file size
  • emoji in Reaction is valid UTF-8 (protocol doesn’t restrict format)
  • timeout_secs in TypingIndicator is a valid u8
  • Edit operations: sender MUST match original message’s sender (client responsibility)
  • Edit operations: At least one of new_text or new_persona_id MUST be Some
  • Edit operations: If new_persona_id provided, it must be a valid persona for the sender
  • PersonaUpdate: updated_persona_id must be Default or valid Id(NonZeroU16)
  • PersonaUpdate: updated_persona must be a valid Persona structure
  • MarkDeleted operations on messages: sender MUST match original message’s sender (client responsibility)
  • MarkDeleted on files: sender MUST match FileId.uploader (client responsibility)
  • Invariant: Every MessageAction(MessageId, AttachFile) MUST reference a prior Message variant with that MessageId (clients enforce when constructing messages)
  • Checking if sender_persona_id exists in sender’s Personas
  • Enforcing thread membership or hierarchy
  • Validating message references (which messages exist)
  • Persisting or enforcing deletes
  • Handling edit conflicts or order
  • Displaying reactions, read receipts, or typing indicators
  • Security scanning file attachments
  • Rate limiting messages per user
  • Content moderation
  • Validating sticker pack membership
  • Enforcing file retention policies
  • Rendering markdown or other text formatting

Text message in thread as specific persona

Section titled “Text message in thread as specific persona”
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b",
"sender": "sender-device-id",
"thread_id": "random-thread-id",
"sender_persona_id": 0,
"inner": {
"type": "Message",
"data": "Agreed, let's proceed"
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b",
"sender": "sender-device-id",
"thread_id": "random-thread-id",
"sender_persona_id": 0,
"inner": {
"type": "Message",
"data": "That's awesome! :excited:"
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "Message",
"data": "<media_pack/pack-alice-cats/excited-cat>"
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": 2,
"inner": {
"type": "MessageAction",
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"data": {
"type": "Reaction",
"emoji": "thumbs-up",
"add": true
}
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "MessageAction",
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"data": {
"type": "Edit",
"new_text": "Sorry, I meant cat",
"new_persona_id": null
}
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "MessageAction",
"message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b",
"data": {
"type": "MarkDeleted"
}
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"sender": "recipient-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "ReadReceipts",
"data": [
"msg-1",
"msg-2",
"msg-3"
]
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"sender": "sender-device-id",
"thread_id": "random-thread-id",
"sender_persona_id": null,
"inner": {
"type": "TypingIndicator",
"timeout_secs": 10
}
}

Step 1: Send Caption Message

{
"message_id": "caption-msg-id",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "Message",
"data": "Here's the latest contract"
}
}

Step 2: Send file attachment

{
"message_id": "attachment-msg-id",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "MessageAction",
"message_id": "caption-msg-id",
"data": {
"type": "AttachFile",
"filename": "contract.pdf",
"mime_type": "application/pdf",
"file_ref": {
"size": 2500000,
"plaintext_hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
"file_id": {
"uploader": "device-xyz",
"id": 12345
}
},
"alt_text": "Latest contract version"
}
}
}

Step 1: Send empty message

{
"message_id": "empty-msg-id",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "Message",
"data": ""
}
}

Step 2: Send file attachment

{
"message_id": "attachment-msg-id",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "MessageAction",
"message_id": "empty-msg-id",
"data": {
"type": "AttachFile",
"filename": "document.pdf",
"mime_type": "application/pdf",
"file_ref": {
"size": 2500000,
"plaintext_hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
"file_id": {
"uploader": "device-xyz",
"id": 12345
}
},
"alt_text": null
}
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"sender": "recipient-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "FileAction",
"file_id": {
"uploader": "device-xyz",
"id": 12345
},
"data": {
"type": "Request",
"range": null
}
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"sender": "recipient-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "FileAction",
"file_id": {
"uploader": "device-xyz",
"id": 12345
},
"data": {
"type": "Request",
"range": {
"start": 1048576,
"end": 2097152
}
}
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "FileAction",
"file_id": {
"uploader": "device-xyz",
"id": 12345
},
"data": {
"type": "Data",
"start": 1048576,
"data": "cGxhaW50ZXh0X2NodW5rX2Jhc2U2NF9oZXJlLi4u"
}
}
}
{
"message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1",
"sender": "sender-device-id",
"thread_id": null,
"sender_persona_id": null,
"inner": {
"type": "FileAction",
"file_id": {
"uploader": "device-xyz",
"id": 12345
},
"data": {
"type": "MarkDeleted"
}
}
}

File attachments use a request/response protocol via FileAction variants.

  1. Sender sends MessageInner::Message (caption or empty string)
  2. Sender receives message ID and sends MessageAction(MessageId, AttachFile) with file metadata
  3. Recipient receives attachment announcement
  4. Recipient sends FileAction(Request(None)) to request full file
  5. Sender responds with one or more FileAction(Data) messages containing plaintext chunks
  6. MLS encrypts entire CryptidMessage containing chunks
  7. Recipient assembles chunks and verifies received data matches plaintext_hash
  • If transfer interrupted, recipient sends FileAction(Request(Some([start, end])))
  • Specifies byte range to resume from (e.g., [1048576, 2097152])
  • Sender responds with FileAction(Data) containing data from that offset
  • Enables recovery without full re-transmission
  • Recommended chunk size: 512 KB (524,288 bytes)
  • Max chunk recommended: 2 MB to prevent memory issues
  • No protocol-level limit, clients enforce reasonable bounds
  • Each FileAction(Data) chunk specifies its byte offset via start field
  • After receiving all chunks, verify Blake3 hash matches plaintext_hash
  • Reject file if hash mismatch detected
  • Sender can send FileAction(MarkDeleted) to indicate file is no longer available
  • Subsequent FileAction(Request) for that file SHOULD be rejected by sender
  • Clients may cache files locally for offline use
  • Files available only while sender device is online
  • Offline recipients queue FileAction(Request) and fetch when sender comes online
  • Clients should cache all files locally for future use

Because files are always associated with a Message, reactions and edits can be applied to the associated message just like any other message:

  • React to file: Send MessageAction(message_id, Reaction) where message_id is the file’s associated message
  • Edit file caption: Send MessageAction(message_id, Edit) to modify the associated message text
  • Delete file: Send MessageAction(message_id, MarkDeleted) to delete the associated message (and conceptually the file announcement)

This unified model eliminates special cases and provides consistent UX across all message types.


Messages are delivered in the order received by the server (the received_at timestamp), but should be displayed using sender timestamps while validating for clock skew.

Example scenario: Alice’s clock is 5 min fast, Bob’s clock is correct.

SenderSender TimestampWall Time @ SendServer Receive TimeServer Relay OrderDisplay Order
Alice10:00:0509:55:0509:55:091st2nd
Bob09:56:0109:56:0109:56:052nd1st

What happens:

  • Alice sends first (wall time 09:55:05) but her timestamp says 10:00:05
  • Bob sends second (wall time 09:56:01) with correct timestamp 09:56:01
  • Server delivers Alice’s message first (received at 09:55:09), then Bob’s (received at 09:56:05)
  • Clients reorder for display: Bob’s message (09:56:01) before Alice’s (10:00:05) using sender timestamps

Why this matters:

  • Server delivery order follows network arrival (prevents manipulation)
  • Display order follows sender intent (respects conversation flow)
  • Clients must validate timestamps against clock skew to prevent abuse

MLS provides causal ordering within a group through epoch counters:

  • Messages in epoch N are guaranteed to be processed before epoch N+1
  • Messages within the same epoch have no guaranteed order
  • Clients use message_id (UUIDv7 with embedded timestamp) for tie-breaking

Recommended client behavior:

  1. Order by MLS epoch (ascending)
  2. Within same epoch, order by sender timestamp field
  3. If timestamps are equal, order by message_id (UUIDv7 is time-ordered)

Clients MUST validate sender timestamps to prevent abuse:

  • Reject messages with timestamp > 5 minutes in the future
  • Reject messages with timestamp > 5 minutes in the past (relative to local time)
  • Display warning for messages with suspicious timestamps
  • Fall back to received_at for display if sender timestamp is invalid

Edge case: If Alice’s clock is >5 minutes off, her messages are rejected by recipients as invalid timestamps.