diff --git a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 9cffb1ec..e7572cf8 100644
--- a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,13 +1,13 @@
 {
-  "originHash" : "fcc346d6fe86e610ac200cdbbf91c56204df67286546d5079bd9c610ee65953b",
+  "originHash" : "d5cd1b39ed966b59fccd3f0d3d46bcf897088e975a6b8a3622235a7adfacaba6",
   "pins" : [
     {
       "identity" : "ably-cocoa",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/ably/ably-cocoa",
       "state" : {
-        "revision" : "7f639c609e50053abd4590f34333f9472645558a",
-        "version" : "1.2.33"
+        "branch" : "main",
+        "revision" : "63e6f001d06cb7defb6be92f87a831f920eaf8c1"
       }
     },
     {
diff --git a/Example/AblyChatExample.xcodeproj/project.pbxproj b/Example/AblyChatExample.xcodeproj/project.pbxproj
index ba328f7d..bfc10bdb 100644
--- a/Example/AblyChatExample.xcodeproj/project.pbxproj
+++ b/Example/AblyChatExample.xcodeproj/project.pbxproj
@@ -104,6 +104,8 @@
 				Base,
 			);
 			mainGroup = 21F09A932C60CAF00025AF73;
+			packageReferences = (
+			);
 			productRefGroup = 21F09A9D2C60CAF00025AF73 /* Products */;
 			projectDirPath = "";
 			projectRoot = "";
diff --git a/Example/AblyChatExample/AblyChatExampleApp.swift b/Example/AblyChatExample/AblyChatExampleApp.swift
index b7bada32..e476b3f6 100644
--- a/Example/AblyChatExample/AblyChatExampleApp.swift
+++ b/Example/AblyChatExample/AblyChatExampleApp.swift
@@ -4,7 +4,7 @@ import SwiftUI
 struct AblyChatExampleApp: App {
     var body: some Scene {
         WindowGroup {
-            ContentView()
+            MessageDemoView()
         }
     }
 }
diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift
deleted file mode 100644
index a0de7edf..00000000
--- a/Example/AblyChatExample/ContentView.swift
+++ /dev/null
@@ -1,24 +0,0 @@
-import AblyChat
-import SwiftUI
-
-struct ContentView: View {
-    /// Just used to check that we can successfully import and use the AblyChat library. TODO remove this once we start building the library
-    @State private var ablyChatClient = DefaultChatClient(
-        realtime: MockRealtime.create(),
-        clientOptions: ClientOptions()
-    )
-
-    var body: some View {
-        VStack {
-            Image(systemName: "globe")
-                .imageScale(.large)
-                .foregroundStyle(.tint)
-            Text("Hello, world!")
-        }
-        .padding()
-    }
-}
-
-#Preview {
-    ContentView()
-}
diff --git a/Example/AblyChatExample/MessageDemoView.swift b/Example/AblyChatExample/MessageDemoView.swift
new file mode 100644
index 00000000..75a0e907
--- /dev/null
+++ b/Example/AblyChatExample/MessageDemoView.swift
@@ -0,0 +1,150 @@
+import Ably
+import AblyChat
+import SwiftUI
+
+// TODO: This entire file can be removed and replaced with the actual example app we're going with. Leaving it here as a reference to something that is currently working.
+
+let clientId = "" // Set any string as a ClientID here e.g. "John"
+let apiKey = "" // Set your Ably API Key here
+
+struct MessageCell: View {
+    var contentMessage: String
+    var isCurrentUser: Bool
+
+    var body: some View {
+        Text(contentMessage)
+            .padding(12)
+            .foregroundColor(isCurrentUser ? Color.white : Color.black)
+            .background(isCurrentUser ? Color.blue : Color.gray)
+            .cornerRadius(12)
+    }
+}
+
+struct MessageView: View {
+    var currentMessage: Message
+
+    var body: some View {
+        HStack(alignment: .bottom) {
+            if let messageClientId = currentMessage.clientID {
+                if messageClientId == clientId {
+                    Spacer()
+                } else {}
+                MessageCell(
+                    contentMessage: currentMessage.text,
+                    isCurrentUser: messageClientId == clientId
+                )
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.horizontal, 16)
+        .padding(.vertical, 4)
+    }
+}
+
+struct MessageDemoView: View {
+    @State private var messages: [Message] = [] // Store the chat messages
+    @State private var newMessage: String = "" // Store the message user is typing
+    @State private var room: Room? // Keep track of the chat room
+
+    var clientOptions: ARTClientOptions {
+        let options = ARTClientOptions()
+        options.clientId = clientId
+        options.key = apiKey
+        return options
+    }
+
+    var body: some View {
+        VStack {
+            ScrollViewReader { proxy in
+                ScrollView {
+                    LazyVStack(spacing: 0) {
+                        ForEach(messages, id: \.self) { message in
+                            MessageView(currentMessage: message)
+                                .id(message)
+                        }
+                    }
+                    .onChange(of: messages.count) {
+                        withAnimation {
+                            proxy.scrollTo(messages.last, anchor: .bottom)
+                        }
+                    }
+                    .onAppear {
+                        withAnimation {
+                            proxy.scrollTo(messages.last, anchor: .bottom)
+                        }
+                    }
+                }
+
+                // send new message
+                HStack {
+                    TextField("Send a message", text: $newMessage)
+                    #if !os(tvOS)
+                        .textFieldStyle(.roundedBorder)
+                    #endif
+                    Button(action: sendMessage) {
+                        Image(systemName: "paperplane")
+                    }
+                }
+                .padding()
+            }
+            .task {
+                await startChat()
+            }
+        }
+    }
+
+    func startChat() async {
+        let realtime = ARTRealtime(options: clientOptions)
+
+        let chatClient = DefaultChatClient(
+            realtime: realtime,
+            clientOptions: nil
+        )
+
+        do {
+            // Get the chat room
+            room = try await chatClient.rooms.get(roomID: "umairsDemoRoom1", options: .init())
+
+            // attach to room
+            try await room?.attach()
+
+            // subscribe to messages
+            let subscription = try await room?.messages.subscribe(bufferingPolicy: .unbounded)
+
+            // use subscription to get previous messages
+            let prevMessages = try await subscription?.getPreviousMessages(params: .init(orderBy: .oldestFirst))
+
+            // init local messages array with previous messages
+            messages = .init(prevMessages?.items ?? [])
+
+            // append new messages to local messages array as they are emitted
+            if let subscription {
+                for await message in subscription {
+                    messages.append(message)
+                }
+            }
+        } catch {
+            print("Error starting chat: \(error)")
+        }
+    }
+
+    func sendMessage() {
+        guard !newMessage.isEmpty else {
+            return
+        }
+        Task {
+            do {
+                _ = try await room?.messages.send(params: .init(text: newMessage))
+
+                // Clear the text field after sending
+                newMessage = ""
+            } catch {
+                print("Error sending message: \(error)")
+            }
+        }
+    }
+}
+
+#Preview {
+    MessageDemoView()
+}
diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift
index 067e8f69..b78a3f74 100644
--- a/Example/AblyChatExample/Mocks/MockRealtime.swift
+++ b/Example/AblyChatExample/Mocks/MockRealtime.swift
@@ -1,8 +1,13 @@
-import Ably
+@preconcurrency import Ably
 import AblyChat
 
+// swiftlint:disable:next line_length
 /// A mock implementation of `RealtimeClientProtocol`. It only exists so that we can construct an instance of `DefaultChatClient` without needing to create a proper `ARTRealtime` instance (which we can’t yet do because we don’t have a method for inserting an API key into the example app). TODO remove this once we start building the example app
 final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
+    func request(_: String, path _: String, params _: [String: String]?, body _: Any?, headers _: [String: String]?, callback _: @escaping ARTHTTPPaginatedCallback) throws {
+        fatalError("not implemented")
+    }
+
     var device: ARTLocalDevice {
         fatalError("Not implemented")
     }
@@ -14,6 +19,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
     let channels = Channels()
 
     final class Channels: RealtimeChannelsProtocol {
+        func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel {
+            fatalError("Not implemented")
+        }
+
         func get(_: String) -> Channel {
             fatalError("Not implemented")
         }
@@ -32,6 +41,13 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
     }
 
     final class Channel: RealtimeChannelProtocol {
+        // Let 'defaultChannelOptions' is not concurrency-safe because non-'Sendable' type 'ARTRealtimeChannelOptions' may have shared mutable state - marked Ably import with @preconcurrency for now.
+        let properties: ARTChannelProperties
+
+        init(properties: ARTChannelProperties) {
+            self.properties = properties
+        }
+
         var state: ARTRealtimeChannelState {
             fatalError("Not implemented")
         }
diff --git a/Package.resolved b/Package.resolved
index 9cffb1ec..e7572cf8 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,13 +1,13 @@
 {
-  "originHash" : "fcc346d6fe86e610ac200cdbbf91c56204df67286546d5079bd9c610ee65953b",
+  "originHash" : "d5cd1b39ed966b59fccd3f0d3d46bcf897088e975a6b8a3622235a7adfacaba6",
   "pins" : [
     {
       "identity" : "ably-cocoa",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/ably/ably-cocoa",
       "state" : {
-        "revision" : "7f639c609e50053abd4590f34333f9472645558a",
-        "version" : "1.2.33"
+        "branch" : "main",
+        "revision" : "63e6f001d06cb7defb6be92f87a831f920eaf8c1"
       }
     },
     {
diff --git a/Package.swift b/Package.swift
index fc233cd2..78236db6 100644
--- a/Package.swift
+++ b/Package.swift
@@ -5,7 +5,7 @@ import PackageDescription
 let package = Package(
     name: "AblyChat",
     platforms: [
-        .macOS(.v11),
+        .macOS(.v12),
         .iOS(.v14),
         .tvOS(.v14),
     ],
@@ -20,7 +20,7 @@ let package = Package(
     dependencies: [
         .package(
             url: "https://github.com/ably/ably-cocoa",
-            from: "1.2.0"
+            branch: "main"
         ),
         .package(
             url: "https://github.com/apple/swift-argument-parser",
diff --git a/Package@swift-6.swift b/Package@swift-6.swift
new file mode 100644
index 00000000..78236db6
--- /dev/null
+++ b/Package@swift-6.swift
@@ -0,0 +1,68 @@
+// swift-tools-version: 6.0
+
+import PackageDescription
+
+let package = Package(
+    name: "AblyChat",
+    platforms: [
+        .macOS(.v12),
+        .iOS(.v14),
+        .tvOS(.v14),
+    ],
+    products: [
+        .library(
+            name: "AblyChat",
+            targets: [
+                "AblyChat",
+            ]
+        ),
+    ],
+    dependencies: [
+        .package(
+            url: "https://github.com/ably/ably-cocoa",
+            branch: "main"
+        ),
+        .package(
+            url: "https://github.com/apple/swift-argument-parser",
+            from: "1.5.0"
+        ),
+        .package(
+            url: "https://github.com/apple/swift-async-algorithms",
+            from: "1.0.1"
+        ),
+    ],
+    targets: [
+        .target(
+            name: "AblyChat",
+            dependencies: [
+                .product(
+                    name: "Ably",
+                    package: "ably-cocoa"
+                ),
+            ]
+        ),
+        .testTarget(
+            name: "AblyChatTests",
+            dependencies: [
+                "AblyChat",
+                .product(
+                    name: "AsyncAlgorithms",
+                    package: "swift-async-algorithms"
+                ),
+            ]
+        ),
+        .executableTarget(
+            name: "BuildTool",
+            dependencies: [
+                .product(
+                    name: "ArgumentParser",
+                    package: "swift-argument-parser"
+                ),
+                .product(
+                    name: "AsyncAlgorithms",
+                    package: "swift-async-algorithms"
+                ),
+            ]
+        ),
+    ]
+)
diff --git a/Sources/AblyChat/ChatAPI.swift b/Sources/AblyChat/ChatAPI.swift
new file mode 100644
index 00000000..76ce0af5
--- /dev/null
+++ b/Sources/AblyChat/ChatAPI.swift
@@ -0,0 +1,139 @@
+import Ably
+
+public final class ChatAPI: Sendable {
+    private let realtime: RealtimeClient
+    private let apiProtocolVersion: Int = 3
+
+    public init(realtime: RealtimeClient) {
+        self.realtime = realtime
+    }
+
+    internal func getChannel(_ messagesChannelName: String) -> any RealtimeChannelProtocol {
+        realtime.getChannel(messagesChannelName)
+    }
+
+    // (CHA-M6) Messages should be queryable from a paginated REST API.
+    public func getMessages(roomId: String, params: QueryOptions) async throws -> any PaginatedResult<Message> {
+        let endpoint = "/chat/v1/rooms/\(roomId)/messages"
+        return try await makeAuthorizedPaginatedRequest(endpoint, params: params.toDictionary())
+    }
+
+    internal struct SendMessageResponse: Codable {
+        internal let timeserial: String
+        internal let createdAt: Int64
+    }
+
+    // (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method.
+    // (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event).
+    public func sendMessage(roomId: String, params: SendMessageParams) async throws -> Message {
+        let endpoint = "/chat/v1/rooms/\(roomId)/messages"
+        var body: [String: Any] = ["text": params.text]
+
+        // (CHA-M3b) A message may be sent without metadata or headers. When these are not specified by the user, they must be omitted from the REST payload.
+        if let metadata = params.metadata {
+            body["metadata"] = metadata
+
+            // (CHA-M3c) metadata must not contain the key ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001.
+            if metadata.contains(where: { $0.key == "ably-chat" }) {
+                throw ARTErrorInfo.create(withCode: 40001, message: "metadata must not contain the key `ably-chat`")
+            }
+        }
+
+        if let headers = params.headers {
+            body["headers"] = headers
+
+            // (CHA-M3d) headers must not contain a key prefixed with ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001.
+            if headers.keys.contains(where: { keyString in
+                keyString.hasPrefix("ably-chat")
+            }) {
+                throw ARTErrorInfo.create(withCode: 40001, message: "headers must not contain any key with a prefix of `ably-chat`")
+            }
+        }
+
+        let response: SendMessageResponse = try await makeAuthorizedRequest(endpoint, method: "POST", body: body)
+
+        // response.createdAt is in milliseconds, convert it to seconds
+        let createdAtInSeconds = TimeInterval(integerLiteral: response.createdAt) / 1000
+
+        let message = Message(
+            timeserial: response.timeserial,
+            clientID: realtime.clientId ?? "",
+            roomID: roomId,
+            text: params.text,
+            createdAt: Date(timeIntervalSince1970: createdAtInSeconds),
+            metadata: params.metadata ?? [:],
+            headers: params.headers ?? [:]
+        )
+        return message
+    }
+
+    public func getOccupancy(roomId: String) async throws -> OccupancyEvent {
+        let endpoint = "/chat/v1/rooms/\(roomId)/occupancy"
+        return try await makeAuthorizedRequest(endpoint, method: "GET")
+    }
+
+    // TODO: Improve as part of occupancy/presence
+    private func makeAuthorizedRequest<Response: Codable>(_ url: String, method: String, body: [String: Any]? = nil) async throws -> Response {
+        try await withCheckedThrowingContinuation { continuation in
+            do {
+                try realtime.request(method, path: url, params: [:], body: body, headers: [:]) { paginatedResponse, error in
+                    if let error {
+                        // (CHA-M3e) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call.
+                        continuation.resume(throwing: ARTErrorInfo.create(from: error))
+                        return
+                    }
+
+                    guard let firstItem = paginatedResponse?.items.first else {
+                        continuation.resume(throwing: ChatError.noItemInResponse)
+                        return
+                    }
+
+                    do {
+                        let decodedResponse = try DictionaryDecoder().decode(Response.self, from: firstItem)
+                        continuation.resume(returning: decodedResponse)
+                    } catch {
+                        continuation.resume(throwing: error)
+                    }
+                }
+            } catch {
+                continuation.resume(throwing: error)
+            }
+        }
+    }
+
+    private func makeAuthorizedPaginatedRequest<Response: Codable & Sendable>(
+        _ url: String,
+        params: [String: String]? = nil,
+        body: [String: Any]? = nil
+    ) async throws -> any PaginatedResult<Response> {
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<PaginatedResultWrapper<Response>, _>) in
+            do {
+                try realtime.request("GET", path: url, params: params, body: nil, headers: [:]) { paginatedResponse, error in
+                    ARTHTTPPaginatedCallbackWrapper<Response>(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation)
+                }
+            } catch {
+                continuation.resume(throwing: error)
+            }
+        }
+    }
+
+    internal enum ChatError: Error {
+        case noItemInResponse
+    }
+}
+
+internal struct DictionaryDecoder {
+    private let decoder = JSONDecoder()
+
+    // Function to decode from a dictionary
+    internal func decode<T: Decodable>(_: T.Type, from dictionary: NSDictionary) throws -> T {
+        let data = try JSONSerialization.data(withJSONObject: dictionary)
+        return try decoder.decode(T.self, from: data)
+    }
+
+    // Function to decode from a dictionary array
+    internal func decode<T: Decodable>(_: T.Type, from dictionary: [NSDictionary]) throws -> T {
+        let data = try JSONSerialization.data(withJSONObject: dictionary)
+        return try decoder.decode(T.self, from: data)
+    }
+}
diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift
new file mode 100644
index 00000000..adf62bc8
--- /dev/null
+++ b/Sources/AblyChat/DefaultMessages.swift
@@ -0,0 +1,235 @@
+import Ably
+
+// Typealias for the timeserial used to sync message subscriptions with. This is a string representation of a timestamp.
+private typealias FromSerial = String
+
+// Wraps the MessageSubscription with the timeserial of when the subscription was attached or resumed.
+private struct MessageSubscriptionWrapper {
+    let subscription: MessageSubscription
+    var timeserial: FromSerial
+}
+
+public final class DefaultMessages: Messages, HandlesDiscontinuity {
+    private let roomID: String
+    public let channel: RealtimeChannelProtocol
+    private let chatAPI: ChatAPI
+    private let clientID: String
+    private var subscriptionPoints: [UUID: MessageSubscriptionWrapper] = [:]
+
+    public nonisolated init(chatAPI: ChatAPI, roomID: String, clientID: String) {
+        self.chatAPI = chatAPI
+        self.roomID = roomID
+        self.clientID = clientID
+
+        // (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel <roomId>::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages.
+        let messagesChannelName = "\(roomID)::$chat::$chatMessages"
+        channel = chatAPI.getChannel(messagesChannelName)
+
+        // Implicitly handles channel events and therefore listners within this class. Alternative is to explicitly call something like `DefaultMessages.start()` which makes the SDK more cumbersome to interact with. This class is useless without kicking off this flow so I think leaving it here is suitable.
+        Task {
+            await handleChannelEvents(roomId: roomID)
+        }
+    }
+
+    // (CHA-M4) Messages can be received via a subscription in realtime.
+    public func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription {
+        let uuid = UUID()
+        let timeserial = try await resolveSubscriptionStart()
+        let messageSubscription = MessageSubscription(
+            bufferingPolicy: bufferingPolicy
+        ) { [weak self] queryOptions in
+            guard let self else { throw MessagesError.noReferenceToSelf }
+            return try await getBeforeSubscriptionStart(uuid, params: queryOptions)
+        }
+
+        subscriptionPoints[uuid] = .init(subscription: messageSubscription, timeserial: timeserial)
+
+        // (CHA-M4c) When a realtime message with name set to message.created is received, it is translated into a message event, which contains a type field with the event type as well as a message field containing the Message Struct. This event is then broadcast to all subscribers.
+        // (CHA-M4d) If a realtime message with an unknown name is received, the SDK shall silently discard the message, though it may log at DEBUG or TRACE level.
+        // (CHA-M5d) Incoming realtime events that are malformed (unknown field should be ignored) shall not be emitted to subscribers.
+        channel.subscribe(MessageEvent.created.rawValue) { message in
+            Task {
+                guard let data = message.data as? [String: Any],
+                      let text = data["text"] as? String
+                else {
+                    return
+                }
+
+                guard let timeserial = try message.extras?.toJSON()["timeserial"] as? String else {
+                    return
+                }
+
+                let message = Message(
+                    timeserial: timeserial,
+                    clientID: message.clientId,
+                    roomID: self.roomID,
+                    text: text,
+                    createdAt: message.timestamp,
+                    metadata: .init(),
+                    headers: .init()
+                )
+
+                messageSubscription.emit(message)
+            }
+        }
+
+        return messageSubscription
+    }
+
+    // (CHA-M6a) A method must be exposed that accepts the standard Ably REST API query parameters. It shall call the “REST API”#rest-fetching-messages and return a PaginatedResult containing messages, which can then be paginated through.
+    public func get(options: QueryOptions) async throws -> any PaginatedResult<Message> {
+        try await chatAPI.getMessages(roomId: roomID, params: options)
+    }
+
+    public func send(params: SendMessageParams) async throws -> Message {
+        try await chatAPI.sendMessage(roomId: roomID, params: params)
+    }
+
+    // TODO: (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. - https://github.com/ably-labs/ably-chat-swift/issues/47
+    public nonisolated func subscribeToDiscontinuities() -> Subscription<ARTErrorInfo> {
+        fatalError("not implemented")
+    }
+
+    public nonisolated func discontinuityDetected(reason: ARTErrorInfo?) {
+        print("Discontinuity detected: \(reason ?? .createUnknownError())")
+    }
+
+    private func getBeforeSubscriptionStart(_ uuid: UUID, params: QueryOptions) async throws -> any PaginatedResult<Message> {
+        guard let subscriptionPoint = subscriptionPoints[uuid]?.timeserial else {
+            throw ARTErrorInfo.create(
+                withCode: 40000,
+                status: 400,
+                message: "cannot query history; listener has not been subscribed yet"
+            )
+        }
+
+        // (CHA-M5j) If the end parameter is specified and is more recent than the subscription point timeserial, the method must throw an ErrorInfo with code 40000.
+        let parseSerial = try? DefaultTimeserial.calculateTimeserial(from: subscriptionPoint)
+        if let end = params.end, end > parseSerial?.timestamp ?? 0 {
+            throw ARTErrorInfo.create(
+                withCode: 40000,
+                status: 400,
+                message: "cannot query history; end time is after the subscription point of the listener"
+            )
+        }
+
+        // (CHA-M5f) This method must accept any of the standard history query options, except for direction, which must always be backwards.
+        var queryOptions = params
+        queryOptions.orderBy = .newestFirst // newestFirst is equivalent to backwards
+
+        // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter.
+        queryOptions.timeSerial = subscriptionPoint
+
+        return try await chatAPI.getMessages(roomId: roomID, params: queryOptions)
+    }
+
+    private func handleChannelEvents(roomId _: String) {
+        // (CHA-M5c) If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial.
+        channel.on(.attached) { [weak self] stateChange in
+            Task {
+                do {
+                    try await self?.handleAttach(fromResume: stateChange.resumed)
+                    return
+                } catch {
+                    throw ARTErrorInfo.create(from: error)
+                }
+            }
+        }
+
+        // (CHA-M4d) If a channel UPDATE event is received and resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial.
+        channel.on(.update) { [weak self] stateChange in
+            if stateChange.current == .attached, stateChange.previous == .attached {
+                Task {
+                    do {
+                        try await self?.handleAttach(fromResume: stateChange.resumed)
+                        return
+                    } catch {
+                        throw ARTErrorInfo.create(from: error)
+                    }
+                }
+            }
+        }
+    }
+
+    // (CHA-M4a) A subscription can be registered to receive incoming messages. Adding a subscription has no side effects on the status of the room or the underlying realtime channel.
+    private func handleAttach(fromResume: Bool) async throws {
+        // Do nothing if we have resumed as there is no discontinuity in the message stream
+        if fromResume {
+            return
+        }
+
+        do {
+            let newSubscriptionStartResolver = try await subscribeAtChannelAttach()
+
+            for uuid in subscriptionPoints.keys {
+                subscriptionPoints[uuid]?.timeserial = newSubscriptionStartResolver
+            }
+        } catch {
+            throw ARTErrorInfo.create(from: error)
+        }
+    }
+
+    private func resolveSubscriptionStart() async throws -> FromSerial {
+        // (CHA-M5a) If a subscription is added when the underlying realtime channel is ATTACHED, then the subscription point is the current channelSerial of the realtime channel.
+        if channel.state == .attached {
+            if let channelSerial = channel.properties.channelSerial {
+                return channelSerial
+            } else {
+                throw ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined")
+            }
+        }
+
+        // (CHA-M5b) If a subscription is added when the underlying realtime channel is in any other state, then its subscription point becomes the attachSerial at the the point of channel attachment.
+        return try await subscribeAtChannelAttach()
+    }
+
+    // (CHA-M4b) A subscription can de-registered from incoming messages. Removing a subscription has no side effects on the status of the room or the underlying realtime channel.
+    private func removeSubscriptionPoint(_ uuid: UUID) {
+        subscriptionPoints.removeValue(forKey: uuid)
+    }
+
+    // Always returns the attachSerial and not the channelSerial to also serve (CHA-M5c) - If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial.
+    private func subscribeAtChannelAttach() async throws -> String {
+        // If the state is already 'attached', return the attachSerial immediately
+        if channel.state == .attached {
+            if let attachSerial = channel.properties.attachSerial {
+                return attachSerial
+            } else {
+                throw ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")
+            }
+        }
+
+        // (CHA-M5b) If a subscription is added when the underlying realtime channel is in any other state, then its subscription point becomes the attachSerial at the the point of channel attachment.
+        return try await withCheckedThrowingContinuation { continuation in
+            channel.on { [weak self] stateChange in
+                guard let self else {
+                    return
+                }
+                switch stateChange.current {
+                case .attached:
+                    // Handle successful attachment
+                    if let attachSerial = channel.properties.attachSerial {
+                        continuation.resume(returning: attachSerial)
+                    } else {
+                        continuation.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined"))
+                    }
+                case .failed, .suspended:
+                    // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32
+                    continuation.resume(
+                        throwing: ARTErrorInfo.create(
+                            withCode: ErrorCode.messagesAttachmentFailed.rawValue,
+                            status: ErrorCode.messagesAttachmentFailed.statusCode,
+                            message: "Channel failed to attach"
+                        )
+                    )
+                default:
+                    break
+                }
+            }
+        }
+    }
+
+    internal enum MessagesError: Error {
+        case noReferenceToSelf
+    }
+}
diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift
index e0d1e184..ecf5edd9 100644
--- a/Sources/AblyChat/Dependencies.swift
+++ b/Sources/AblyChat/Dependencies.swift
@@ -15,8 +15,25 @@ public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable
     associatedtype Channel: RealtimeChannelProtocol
 
     // It’s not clear to me why ARTRealtimeChannelsProtocol doesn’t include this property (https://github.com/ably/ably-cocoa/issues/1968).
+    func get(_ name: String, options: ARTRealtimeChannelOptions) -> Channel
     func get(_ name: String) -> Channel
 }
 
 /// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``.
 public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {}
+
+internal extension RealtimeClientProtocol {
+    // Function to get the channel with merged options
+    func getChannel(_ name: String, opts: ARTRealtimeChannelOptions? = nil) -> any RealtimeChannelProtocol {
+        // Merge opts and defaultChannelOptions
+        let resolvedOptions = opts ?? ARTRealtimeChannelOptions()
+
+        // Merge params if available, using defaultChannelOptions as fallback
+        resolvedOptions.params = opts?.params?.merging(
+            defaultChannelOptions.params ?? [:]
+        ) { _, new in new }
+
+        // Return the resolved channel
+        return channels.get(name, options: resolvedOptions)
+    }
+}
diff --git a/Sources/AblyChat/EmitsDiscontinuities.swift b/Sources/AblyChat/EmitsDiscontinuities.swift
index ed3119f7..e9a0d5e5 100644
--- a/Sources/AblyChat/EmitsDiscontinuities.swift
+++ b/Sources/AblyChat/EmitsDiscontinuities.swift
@@ -3,3 +3,18 @@ import Ably
 public protocol EmitsDiscontinuities {
     func subscribeToDiscontinuities() -> Subscription<ARTErrorInfo>
 }
+
+/**
+ * Represents an object that has a channel and therefore may care about discontinuities.
+ */
+@MainActor
+internal protocol HandlesDiscontinuity {
+    var channel: RealtimeChannelProtocol { get }
+
+//    var channel: RealtimeChannelProtocol? { get }
+    /**
+     * Called when a discontinuity is detected on the channel.
+     * @param reason The error that caused the discontinuity.
+     */
+    func discontinuityDetected(reason: ARTErrorInfo?)
+}
diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift
new file mode 100644
index 00000000..cd2d5fb0
--- /dev/null
+++ b/Sources/AblyChat/Events.swift
@@ -0,0 +1,3 @@
+internal enum MessageEvent: String {
+    case created = "message.created"
+}
diff --git a/Sources/AblyChat/Headers.swift b/Sources/AblyChat/Headers.swift
index 9735a7fe..febf6be8 100644
--- a/Sources/AblyChat/Headers.swift
+++ b/Sources/AblyChat/Headers.swift
@@ -1,8 +1,8 @@
 import Foundation
 
-public enum HeadersValue: Sendable {
+public enum HeadersValue: Sendable, Codable, Hashable {
     case string(String)
-    case number(NSNumber)
+    case number(Int)
     case bool(Bool)
     case null
 }
diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift
index 92ce94f4..ff2991b3 100644
--- a/Sources/AblyChat/Message.swift
+++ b/Sources/AblyChat/Message.swift
@@ -3,16 +3,18 @@ import Foundation
 public typealias MessageHeaders = Headers
 public typealias MessageMetadata = Metadata
 
-public struct Message: Sendable {
+// (CHA-M2) A Message corresponds to a single message in a chat room. This is analogous to a single user-specified message on an Ably channel (NOTE: not a ProtocolMessage).
+// Must conform to Hashable to use some SwiftUI niceties e.g. 'ForEach'.
+public struct Message: Sendable, Codable, Hashable {
     public var timeserial: String
-    public var clientID: String
+    public var clientID: String?
     public var roomID: String
     public var text: String
-    public var createdAt: Date
+    public var createdAt: Date?
     public var metadata: MessageMetadata
     public var headers: MessageHeaders
 
-    public init(timeserial: String, clientID: String, roomID: String, text: String, createdAt: Date, metadata: MessageMetadata, headers: MessageHeaders) {
+    public init(timeserial: String, clientID: String?, roomID: String, text: String, createdAt: Date?, metadata: MessageMetadata, headers: MessageHeaders) {
         self.timeserial = timeserial
         self.clientID = clientID
         self.roomID = roomID
@@ -22,15 +24,31 @@ public struct Message: Sendable {
         self.headers = headers
     }
 
-    public func isBefore(_: Message) -> Bool {
-        fatalError("Not yet implemented")
+    internal enum CodingKeys: String, CodingKey {
+        case timeserial
+        case clientID = "clientId"
+        case roomID = "roomId"
+        case text
+        case createdAt
+        case metadata
+        case headers
     }
 
-    public func isAfter(_: Message) -> Bool {
-        fatalError("Not yet implemented")
+    // (CHA-M2a) A Message is considered before another Message in the global order if the timeserial of the corresponding realtime channel message comes first.
+    public func isBefore(_ otherMessage: Message) throws -> Bool {
+        let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial)
+        return try DefaultTimeserial.calculateTimeserial(from: timeserial).before(otherMessageTimeserial)
     }
 
-    public func isEqual(_: Message) -> Bool {
-        fatalError("Not yet implemented")
+    // CHA-M2b) A Message is considered after another Message in the global order if the timeserial of the corresponding realtime channel message comes second.
+    public func isAfter(_ otherMessage: Message) throws -> Bool {
+        let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial)
+        return try DefaultTimeserial.calculateTimeserial(from: timeserial).after(otherMessageTimeserial)
+    }
+
+    // (CHA-M2c) A Message is considered to be equal to another Message if they have the same timeserial.
+    public func isEqual(_ otherMessage: Message) throws -> Bool {
+        let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial)
+        return try DefaultTimeserial.calculateTimeserial(from: timeserial).equal(otherMessageTimeserial)
     }
 }
diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift
index 26a484d9..fc05c23f 100644
--- a/Sources/AblyChat/Messages.swift
+++ b/Sources/AblyChat/Messages.swift
@@ -1,10 +1,12 @@
 import Ably
 
+// MainActor is required to resolve a Swift 6.0 error "Incorrect actor executor assumption", whilst also allowing for mutations on listeners within the concrete class.
+@MainActor
 public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities {
-    func subscribe(bufferingPolicy: BufferingPolicy) -> MessageSubscription
+    func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription
     func get(options: QueryOptions) async throws -> any PaginatedResult<Message>
     func send(params: SendMessageParams) async throws -> Message
-    var channel: ARTRealtimeChannelProtocol { get }
+    var channel: RealtimeChannelProtocol { get }
 }
 
 public struct SendMessageParams: Sendable {
@@ -19,6 +21,7 @@ public struct SendMessageParams: Sendable {
     }
 }
 
+// TODO: Start and End can be Dates in Swift so should be... will revisit this to properly convert from a timeserial represented as an Int to a Date and back. https://github.com/ably-labs/ably-chat-swift/issues/78
 public struct QueryOptions: Sendable {
     public enum ResultOrder: Sendable {
         case oldestFirst
@@ -26,11 +29,14 @@ public struct QueryOptions: Sendable {
     }
 
     public var start: Date?
-    public var end: Date?
+    public var end: Int? // represented as timeserial, represented as a number in JS.
     public var limit: Int?
     public var orderBy: ResultOrder?
 
-    public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, orderBy: QueryOptions.ResultOrder? = nil) {
+    // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter.
+    internal var timeSerial: String?
+
+    public init(start: Date? = nil, end: Int? = nil, limit: Int? = nil, orderBy: QueryOptions.ResultOrder? = nil) {
         self.start = start
         self.end = end
         self.limit = limit
@@ -38,15 +44,35 @@ public struct QueryOptions: Sendable {
     }
 }
 
-public struct QueryOptionsWithoutDirection: Sendable {
-    public var start: Date?
-    public var end: Date?
-    public var limit: Int?
+public extension QueryOptions {
+    func toDictionary() -> [String: String] {
+        var dict: [String: String] = [:]
+        if let start {
+            dict["start"] = "\(start)"
+        }
 
-    public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil) {
-        self.start = start
-        self.end = end
-        self.limit = limit
+        if let end {
+            dict["end"] = "\(end)"
+        }
+
+        if let limit {
+            dict["limit"] = "\(limit)"
+        }
+
+        if let orderBy {
+            switch orderBy {
+            case .oldestFirst:
+                dict["direction"] = "forwards"
+            case .newestFirst:
+                dict["direction"] = "backwards"
+            }
+        }
+
+        if let timeSerial {
+            dict["fromSerial"] = timeSerial
+        }
+
+        return dict
     }
 }
 
@@ -56,26 +82,30 @@ public struct MessageSubscription: Sendable, AsyncSequence {
 
     private var subscription: Subscription<Element>
 
-    private var mockGetPreviousMessages: (@Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult<Message>)?
+    // can be set by either initialiser
+    private let getPreviousMessages: @Sendable (QueryOptions) async throws -> any PaginatedResult<Message>
 
-    internal init(bufferingPolicy: BufferingPolicy) {
+    // used internally
+    internal init(
+        bufferingPolicy: BufferingPolicy,
+        getPreviousMessages: @escaping @Sendable (QueryOptions) async throws -> any PaginatedResult<Message>
+    ) {
         subscription = .init(bufferingPolicy: bufferingPolicy)
+        self.getPreviousMessages = getPreviousMessages
     }
 
-    public init<T: AsyncSequence & Sendable>(mockAsyncSequence: T, mockGetPreviousMessages: @escaping @Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult<Message>) where T.Element == Element {
+    // used for testing
+    public init<T: AsyncSequence & Sendable>(mockAsyncSequence: T, mockGetPreviousMessages: @escaping @Sendable (QueryOptions) async throws -> any PaginatedResult<Message>) where T.Element == Element {
         subscription = .init(mockAsyncSequence: mockAsyncSequence)
-        self.mockGetPreviousMessages = mockGetPreviousMessages
+        getPreviousMessages = mockGetPreviousMessages
     }
 
     internal func emit(_ element: Element) {
         subscription.emit(element)
     }
 
-    public func getPreviousMessages(params: QueryOptionsWithoutDirection) async throws -> any PaginatedResult<Message> {
-        guard let mockImplementation = mockGetPreviousMessages else {
-            fatalError("Not yet implemented")
-        }
-        return try await mockImplementation(params)
+    public func getPreviousMessages(params: QueryOptions) async throws -> any PaginatedResult<Message> {
+        try await getPreviousMessages(params)
     }
 
     public struct AsyncIterator: AsyncIteratorProtocol {
diff --git a/Sources/AblyChat/Metadata.swift b/Sources/AblyChat/Metadata.swift
index e6f94f01..adf00eb1 100644
--- a/Sources/AblyChat/Metadata.swift
+++ b/Sources/AblyChat/Metadata.swift
@@ -1,2 +1,11 @@
 // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type
-public typealias Metadata = [String: (any Sendable)?]
+// I attempted to address this issue by making a struct conforming to Codable which would at least give us some safety in knowing items can be encoded and decoded. However, the requirement for `Messages` to be Hashable made this difficult. Gone for the same approach as Headers for now, we can investigate whether we need to be open to more types than this later.
+
+public enum MetadataValue: Sendable, Codable, Hashable {
+    case string(String)
+    case number(Int)
+    case bool(Bool)
+    case null
+}
+
+public typealias Metadata = [String: MetadataValue?]
diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift
index 58c68078..550f11dc 100644
--- a/Sources/AblyChat/Occupancy.swift
+++ b/Sources/AblyChat/Occupancy.swift
@@ -6,7 +6,7 @@ public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities {
     var channel: ARTRealtimeChannelProtocol { get }
 }
 
-public struct OccupancyEvent: Sendable {
+public struct OccupancyEvent: Sendable, Encodable, Decodable {
     public var connections: Int
     public var presenceMembers: Int
 
diff --git a/Sources/AblyChat/PaginatedResult.swift b/Sources/AblyChat/PaginatedResult.swift
index da3142d1..6d2680c0 100644
--- a/Sources/AblyChat/PaginatedResult.swift
+++ b/Sources/AblyChat/PaginatedResult.swift
@@ -1,3 +1,5 @@
+import Ably
+
 public protocol PaginatedResult<T>: AnyObject, Sendable {
     associatedtype T
 
@@ -9,3 +11,85 @@ public protocol PaginatedResult<T>: AnyObject, Sendable {
     var first: any PaginatedResult<T> { get async throws }
     var current: any PaginatedResult<T> { get async throws }
 }
+
+/// Used internally to reduce the amount of duplicate code when interacting with `ARTHTTPPaginatedCallback`'s. The wrapper takes in the callback result from the caller e.g. `realtime.request` and either throws the appropriate error, or decodes and returns the response.
+internal struct ARTHTTPPaginatedCallbackWrapper<Response: Codable & Sendable> {
+    internal let callbackResult: (ARTHTTPPaginatedResponse?, ARTErrorInfo?)
+
+    internal func handleResponse(continuation: CheckedContinuation<PaginatedResultWrapper<Response>, any Error>) {
+        let (paginatedResponse, error) = callbackResult
+
+        // (CHA-M5i) If the REST API returns an error, then the method must throw its ErrorInfo representation.
+        // (CHA-M6b) If the REST API returns an error, then the method must throw its ErrorInfo representation.
+        if let error {
+            continuation.resume(throwing: ARTErrorInfo.create(from: error))
+            return
+        }
+
+        guard let paginatedResponse, paginatedResponse.statusCode == 200 else {
+            continuation.resume(throwing: PaginatedResultError.noErrorWithInvalidResponse)
+            return
+        }
+
+        do {
+            let decodedResponse = try DictionaryDecoder().decode([Response].self, from: paginatedResponse.items)
+            let result = paginatedResponse.toPaginatedResult(items: decodedResponse)
+            continuation.resume(returning: result)
+        } catch {
+            continuation.resume(throwing: error)
+        }
+    }
+
+    internal enum PaginatedResultError: Error {
+        case noErrorWithInvalidResponse
+    }
+}
+
+/// `PaginatedResult` protocol implementation allowing access to the underlying items from a lower level paginated response object e.g. `ARTHTTPPaginatedResponse`, whilst succinctly handling errors through the use of `ARTHTTPPaginatedCallbackWrapper`.
+internal final class PaginatedResultWrapper<T: Codable & Sendable>: PaginatedResult {
+    internal let items: [T]
+    internal let hasNext: Bool
+    internal let isLast: Bool
+    internal let paginatedResponse: ARTHTTPPaginatedResponse
+
+    internal init(paginatedResponse: ARTHTTPPaginatedResponse, items: [T]) {
+        self.items = items
+        hasNext = paginatedResponse.hasNext
+        isLast = paginatedResponse.isLast
+        self.paginatedResponse = paginatedResponse
+    }
+
+    /// Asynchronously fetch the next page if available
+    internal var next: (any PaginatedResult<T>)? {
+        get async throws {
+            try await withCheckedThrowingContinuation { continuation in
+                paginatedResponse.next { paginatedResponse, error in
+                    ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation)
+                }
+            }
+        }
+    }
+
+    /// Asynchronously fetch the first page
+    internal var first: any PaginatedResult<T> {
+        get async throws {
+            try await withCheckedThrowingContinuation { continuation in
+                paginatedResponse.first { paginatedResponse, error in
+                    ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation)
+                }
+            }
+        }
+    }
+
+    /// Asynchronously fetch the current page
+    internal var current: any PaginatedResult<T> {
+        self
+    }
+}
+
+private extension ARTHTTPPaginatedResponse {
+    /// Converts an `ARTHTTPPaginatedResponse` to a `PaginatedResultWrapper` allowing for access to operations as per conformance to `PaginatedResult`.
+    func toPaginatedResult<T: Codable & Sendable>(items: [T]) -> PaginatedResultWrapper<T> {
+        PaginatedResultWrapper(paginatedResponse: self, items: items)
+    }
+}
diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift
index e4660c8d..047504e8 100644
--- a/Sources/AblyChat/Room.swift
+++ b/Sources/AblyChat/Room.swift
@@ -20,6 +20,9 @@ public protocol Room: AnyObject, Sendable {
 internal actor DefaultRoom: Room {
     internal nonisolated let roomID: String
     internal nonisolated let options: RoomOptions
+    private let chatAPI: ChatAPI
+
+    private let _messages: any Messages
 
     // Exposed for testing.
     private nonisolated let realtime: RealtimeClient
@@ -33,16 +36,23 @@ internal actor DefaultRoom: Room {
     private let _status: DefaultRoomStatus
     private let logger: InternalLogger
 
-    internal init(realtime: RealtimeClient, roomID: String, options: RoomOptions, logger: InternalLogger) {
+    internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) {
         self.realtime = realtime
         self.roomID = roomID
         self.options = options
         self.logger = logger
         _status = .init(logger: logger)
+        self.chatAPI = chatAPI
+
+        _messages = DefaultMessages(
+            chatAPI: chatAPI,
+            roomID: roomID,
+            clientID: realtime.clientId ?? ""
+        )
     }
 
     public nonisolated var messages: any Messages {
-        fatalError("Not yet implemented")
+        _messages
     }
 
     public nonisolated var presence: any Presence {
diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift
index 87f1d7c6..7a9df076 100644
--- a/Sources/AblyChat/Rooms.swift
+++ b/Sources/AblyChat/Rooms.swift
@@ -39,7 +39,7 @@ internal actor DefaultRooms: Rooms {
 
             return existingRoom
         } else {
-            let room = DefaultRoom(realtime: realtime, roomID: roomID, options: options, logger: logger)
+            let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: roomID, options: options, logger: logger)
             rooms[roomID] = room
             return room
         }
diff --git a/Sources/AblyChat/Timeserial.swift b/Sources/AblyChat/Timeserial.swift
new file mode 100644
index 00000000..97aadb71
--- /dev/null
+++ b/Sources/AblyChat/Timeserial.swift
@@ -0,0 +1,94 @@
+import Foundation
+
+internal protocol Timeserial: Sendable {
+    var seriesId: String { get }
+    var timestamp: Int { get }
+    var counter: Int { get }
+    var index: Int? { get }
+
+    func before(_ timeserial: Timeserial) -> Bool
+    func after(_ timeserial: Timeserial) -> Bool
+    func equal(_ timeserial: Timeserial) -> Bool
+}
+
+internal struct DefaultTimeserial: Timeserial {
+    internal let seriesId: String
+    internal let timestamp: Int
+    internal let counter: Int
+    internal let index: Int?
+
+    private init(seriesId: String, timestamp: Int, counter: Int, index: Int?) {
+        self.seriesId = seriesId
+        self.timestamp = timestamp
+        self.counter = counter
+        self.index = index
+    }
+
+    // Static method to parse a timeserial string
+    internal static func calculateTimeserial(from timeserial: String) throws -> DefaultTimeserial {
+        let components = timeserial.split(separator: "@")
+        guard components.count == 2, let rest = components.last else {
+            throw TimeserialError.invalidFormat
+        }
+
+        let seriesId = String(components[0])
+        let parts = rest.split(separator: "-")
+        guard parts.count == 2 else {
+            throw TimeserialError.invalidFormat
+        }
+
+        let timestamp = Int(parts[0]) ?? 0
+        let counterAndIndex = parts[1].split(separator: ":")
+        let counter = Int(counterAndIndex[0]) ?? 0
+        let index = counterAndIndex.count > 1 ? Int(counterAndIndex[1]) : nil
+
+        return DefaultTimeserial(seriesId: seriesId, timestamp: timestamp, counter: counter, index: index)
+    }
+
+    // Compare timeserials
+    private func timeserialCompare(_ other: Timeserial) -> Int {
+        // Compare timestamps
+        let timestampDiff = timestamp - other.timestamp
+        if timestampDiff != 0 {
+            return timestampDiff
+        }
+
+        // Compare counters
+        let counterDiff = counter - other.counter
+        if counterDiff != 0 {
+            return counterDiff
+        }
+
+        // Compare seriesId lexicographically
+        if seriesId != other.seriesId {
+            return seriesId < other.seriesId ? -1 : 1
+        }
+
+        // Compare index if present
+        if let idx1 = index, let idx2 = other.index {
+            return idx1 - idx2
+        }
+
+        return 0
+    }
+
+    // Check if this timeserial is before the given timeserial
+    internal func before(_ timeserial: Timeserial) -> Bool {
+        timeserialCompare(timeserial) < 0
+    }
+
+    // Check if this timeserial is after the given timeserial
+    internal func after(_ timeserial: Timeserial) -> Bool {
+        timeserialCompare(timeserial) > 0
+    }
+
+    // Check if this timeserial is equal to the given timeserial
+    internal func equal(_ timeserial: Timeserial) -> Bool {
+        timeserialCompare(timeserial) == 0
+    }
+
+    // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 (should we only throw ARTErrors?)
+    internal enum TimeserialError: Error {
+        case invalidFormat
+    }
+}
diff --git a/Sources/AblyChat/Version.swift b/Sources/AblyChat/Version.swift
new file mode 100644
index 00000000..d7786daa
--- /dev/null
+++ b/Sources/AblyChat/Version.swift
@@ -0,0 +1,18 @@
+@preconcurrency import Ably
+
+// TODO: Just copied chat-js implementation for now to send up agent info. https://github.com/ably-labs/ably-chat-swift/issues/76
+
+// Update this when you release a new version
+// Version information
+public let version = "0.1.0"
+
+// Channel options agent string
+public let channelOptionsAgentString = "chat-ios/\(version)"
+
+// Default channel options
+// Let 'defaultChannelOptions' is not concurrency-safe because non-'Sendable' type 'ARTRealtimeChannelOptions' may have shared mutable state - marked Ably import with @preconcurrency for now.
+public let defaultChannelOptions: ARTRealtimeChannelOptions = {
+    let options = ARTRealtimeChannelOptions()
+    options.params = ["agent": channelOptionsAgentString]
+    return options
+}()
diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift
index 8956a7e0..5969896f 100644
--- a/Tests/AblyChatTests/DefaultRoomTests.swift
+++ b/Tests/AblyChatTests/DefaultRoomTests.swift
@@ -17,7 +17,7 @@ struct DefaultRoomTests {
         ]
         let channels = MockChannels(channels: channelsList)
         let realtime = MockRealtime.create(channels: channels)
-        let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger())
+        let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger())
 
         let subscription = await room.status.onChange(bufferingPolicy: .unbounded)
         async let attachedStatusChange = subscription.first { $0.current == .attached }
@@ -50,7 +50,7 @@ struct DefaultRoomTests {
         ]
         let channels = MockChannels(channels: channelsList)
         let realtime = MockRealtime.create(channels: channels)
-        let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger())
+        let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger())
 
         // When: `attach` is called on the room
         let roomAttachError: Error?
@@ -79,7 +79,7 @@ struct DefaultRoomTests {
         ]
         let channels = MockChannels(channels: channelsList)
         let realtime = MockRealtime.create(channels: channels)
-        let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger())
+        let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger())
 
         let subscription = await room.status.onChange(bufferingPolicy: .unbounded)
         async let detachedStatusChange = subscription.first { $0.current == .detached }
@@ -112,7 +112,7 @@ struct DefaultRoomTests {
         ]
         let channels = MockChannels(channels: channelsList)
         let realtime = MockRealtime.create(channels: channels)
-        let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger())
+        let room = DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger())
 
         // When: `detach` is called on the room
         let roomDetachError: Error?
diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift
index adf9fafd..75da3c1a 100644
--- a/Tests/AblyChatTests/DefaultRoomsTests.swift
+++ b/Tests/AblyChatTests/DefaultRoomsTests.swift
@@ -6,7 +6,7 @@ struct DefaultRoomsTests {
     @Test
     func get_returnsRoomWithGivenID() async throws {
         // Given: an instance of DefaultRooms
-        let realtime = MockRealtime.create()
+        let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")]))
         let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
 
         // When: get(roomID:options:) is called
@@ -25,7 +25,7 @@ struct DefaultRoomsTests {
     @Test
     func get_returnsExistingRoomWithGivenID() async throws {
         // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID
-        let realtime = MockRealtime.create()
+        let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")]))
         let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
 
         let roomID = "basketball"
@@ -43,7 +43,7 @@ struct DefaultRoomsTests {
     @Test
     func get_throwsErrorWhenOptionsDoNotMatch() async throws {
         // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options
-        let realtime = MockRealtime.create()
+        let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")]))
         let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
 
         let roomID = "basketball"
diff --git a/Tests/AblyChatTests/MessageSubscriptionTests.swift b/Tests/AblyChatTests/MessageSubscriptionTests.swift
index 5babe7b3..dd599b7a 100644
--- a/Tests/AblyChatTests/MessageSubscriptionTests.swift
+++ b/Tests/AblyChatTests/MessageSubscriptionTests.swift
@@ -32,7 +32,7 @@ struct MessageSubscriptionTests {
 
     @Test
     func emit() async {
-        let subscription = MessageSubscription(bufferingPolicy: .unbounded)
+        let subscription = MessageSubscription(bufferingPolicy: .unbounded) { _ in fatalError("Not implemented") }
 
         async let emittedElements = Array(subscription.prefix(2))
 
diff --git a/Tests/AblyChatTests/Mocks/MockChannels.swift b/Tests/AblyChatTests/Mocks/MockChannels.swift
index 6cbf82b1..68f6a2eb 100644
--- a/Tests/AblyChatTests/Mocks/MockChannels.swift
+++ b/Tests/AblyChatTests/Mocks/MockChannels.swift
@@ -16,6 +16,10 @@ final class MockChannels: RealtimeChannelsProtocol, Sendable {
         return channel
     }
 
+    func get(_ name: String, options _: ARTRealtimeChannelOptions) -> MockRealtimeChannel {
+        get(name)
+    }
+
     func exists(_: String) -> Bool {
         fatalError("Not implemented")
     }
diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift
index e8c82779..8102bf3e 100644
--- a/Tests/AblyChatTests/Mocks/MockRealtime.swift
+++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift
@@ -9,7 +9,7 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
     }
 
     var clientId: String? {
-        fatalError("Not implemented")
+        "mockClientId"
     }
 
     required init(options _: ARTClientOptions) {
@@ -62,4 +62,8 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
     func close() {
         fatalError("Not implemented")
     }
+
+    func request(_: String, path _: String, params _: [String: String]?, body _: Any?, headers _: [String: String]?, callback _: @escaping ARTHTTPPaginatedCallback) throws {
+        fatalError("Not implemented")
+    }
 }
diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift
index f01f70b2..74c20293 100644
--- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift
+++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift
@@ -1,7 +1,9 @@
-import Ably
+@preconcurrency import Ably
 import AblyChat
 
 final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol {
+    let properties: ARTChannelProperties
+
     private let _name: String?
 
     init(
@@ -12,6 +14,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol {
         _name = name
         self.attachResult = attachResult
         self.detachResult = detachResult
+        properties = .init()
     }
 
     /// A threadsafe counter that starts at zero.
@@ -141,7 +144,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol {
     }
 
     func on(_: ARTChannelEvent, callback _: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener {
-        fatalError("Not implemented")
+        ARTEventListener()
     }
 
     func on(_: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener {