Skip to content

Events

Client events report changes to a client’s state. Use them to update a screen, clear a cache, or show message status. An event usually carries IDs and a small payload. Read the current conversation or message again when you need its full state.

Events are live and are not stored for replay. A listener starts before the setup call returns. It receives changes emitted after registration. Start the listener before you read the initial state, then merge later updates into that state.

An event listener does not start network activity. Message processing, an explicit sync, app operations, or background workers must make the changes it reports. For message processing that must survive a restart, use message streams.

All SDKs provide startListener(filter, callback) and stopListener(id). Browser and Node return a bigint listener ID. Kotlin and Swift return a ListenerId. Keep this ID, or the stop function returned by the JavaScript examples, for as long as the screen or service needs the listener.

The following examples handle received messages, outgoing message status, consent, and conversation changes. Their app callbacks receive the typed payload. The refreshAll callback reads the current state again after event loss. Add your app’s UI or cache updates in these callbacks. For Swift, update UI state through the main actor. For Android, use the app’s UI coroutine scope.

export async function
function listenToClientEvents(client: Client, handlers: EventHandlers): Promise<() => Promise<void>>
listenToClientEvents
(
client: Client
client
:
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
,
handlers: EventHandlers
handlers
:
type EventHandlers = {
messageReceived: (change: MessageReceived) => Promise<void>;
messageStatusChanged: (change: MessageStatusChanged) => Promise<void>;
consentChanged: (change: ConsentChanged) => Promise<void>;
refreshConversation: (groupId: Uint8Array) => Promise<void>;
refreshAll: (discarded: bigint) => Promise<void>;
}
EventHandlers
,
) {
const
const listenerId: bigint
listenerId
= await
client: Client
client
.
Client.startListener(filter: EventFilter, callback: (event: ClientEvent) => void | Promise<void>): Promise<bigint>

Call callback for each client event that filter selects. A callback failure does not stop the listener.

startListener
(
{
kinds: EventKind[]
kinds
: [
"message.received",
"message.status_changed",
"consent.changed",
"conversation.joined",
"conversation.removed",
"conversation.membership_changed",
"conversation.metadata_changed",
],
},
async (
event: ClientEvent
event
) => {
switch (
event: ClientEvent
event
.
kind: "conversation.joined" | "conversation.removed" | "conversation.membership_changed" | "conversation.metadata_changed" | "conversation.paused" | "message.received" | "message.status_changed" | "message.deleted" | "message.expired" | "consent.changed" | "hmac_keys.updated" | "identity.registered" | "identity.own_installation_added" | "identity.own_installation_revoked" | "client.rejected_by_server" | "client.lockout_changed" | "conversation.fork_detected" | "notifications.failed" | "archive.restored" | "connection.state_changed" | ... 8 more ... | "lagged"
kind
) {
case "message.received":
await
handlers: EventHandlers
handlers
.
messageReceived: (change: MessageReceived) => Promise<void>
messageReceived
(
event: {
readonly kind: "message.received";
readonly message_received: MessageReceived;
}
event
.
message_received: MessageReceived
message_received
);
break;
case "message.status_changed":
await
handlers: EventHandlers
handlers
.
messageStatusChanged: (change: MessageStatusChanged) => Promise<void>
messageStatusChanged
(
event: {
readonly kind: "message.status_changed";
readonly message_status_changed: MessageStatusChanged;
}
event
.
message_status_changed: MessageStatusChanged
message_status_changed
);
break;
case "consent.changed":
await
handlers: EventHandlers
handlers
.
consentChanged: (change: ConsentChanged) => Promise<void>
consentChanged
(
event: {
readonly kind: "consent.changed";
readonly consent_changed: ConsentChanged;
}
event
.
consent_changed: ConsentChanged
consent_changed
);
break;
case "conversation.joined":
await
handlers: EventHandlers
handlers
.
refreshConversation: (groupId: Uint8Array) => Promise<void>
refreshConversation
(
event: {
readonly kind: "conversation.joined";
readonly conversation_joined: ConversationJoined;
}
event
.
conversation_joined: ConversationJoined
conversation_joined
.
group_id: Uint8Array<ArrayBufferLike>
group_id
,
);
break;
case "conversation.removed":
await
handlers: EventHandlers
handlers
.
refreshConversation: (groupId: Uint8Array) => Promise<void>
refreshConversation
(
event: {
readonly kind: "conversation.removed";
readonly conversation_removed: ConversationRemoved;
}
event
.
conversation_removed: ConversationRemoved
conversation_removed
.
group_id: Uint8Array<ArrayBufferLike>
group_id
,
);
break;
case "conversation.membership_changed":
await
handlers: EventHandlers
handlers
.
refreshConversation: (groupId: Uint8Array) => Promise<void>
refreshConversation
(
event: {
readonly kind: "conversation.membership_changed";
readonly membership_changed: MembershipChanged;
}
event
.
membership_changed: MembershipChanged
membership_changed
.
group_id: Uint8Array<ArrayBufferLike>
group_id
);
break;
case "conversation.metadata_changed":
await
handlers: EventHandlers
handlers
.
refreshConversation: (groupId: Uint8Array) => Promise<void>
refreshConversation
(
event: {
readonly kind: "conversation.metadata_changed";
readonly metadata_changed: MetadataChanged;
}
event
.
metadata_changed: MetadataChanged
metadata_changed
.
group_id: Uint8Array<ArrayBufferLike>
group_id
);
break;
case "lagged":
await
handlers: EventHandlers
handlers
.
refreshAll: (discarded: bigint) => Promise<void>
refreshAll
(
event: {
readonly kind: "lagged";
readonly lagged: Lagged;
}
event
.
lagged: Lagged
lagged
.
discarded: bigint
discarded
);
break;
default:
break;
}
},
);
return () =>
client: Client
client
.
Client.stopListener(id: bigint): Promise<void>

Stop a listener. Stopping an unknown or stopped listener does nothing.

stopListener
(
const listenerId: bigint
listenerId
);
}

Call the returned JavaScript stop function with await stop(). Stopping an unknown or stopped listener does nothing. client.end() also ends every event subscription of that client. On Agent, stop client event listeners separately; agent.stop() stops the Agent’s message and conversation streams, and leaves its client open.

On Browser, Node, and Agent, switch on event.kind. Payload names and fields use snake case. For example, event.message_status_changed.current is "unpublished", "published", or "failed". This status reports publication by this installation. It is not a read receipt.

On Kotlin, narrow the ClientEvent variant and read its record, such as event.messageStatusChanged. On Swift, match the enum case and its associated record. Native record fields use camel case. EventMessageStatus uses UNPUBLISHED, PUBLISHED, and FAILED on Kotlin, and .unpublished, .published, and .failed on Swift.

Group and message IDs in event payloads are bytes: Uint8Array in JavaScript, ByteArray in Kotlin, and Data in Swift. Conversation and message lookup methods use hex IDs. For JavaScript, this helper converts a group ID and reads its state:

This helper works in both Browser and Node:

export async function
function readConversationState(client: Client, groupId: Uint8Array): Promise<GroupState | ConversationState | undefined>
readConversationState
(
client: Client
client
:
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
,
groupId: Uint8Array<ArrayBufferLike>
groupId
:
interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike>

A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the requested number of bytes could not be allocated an exception is raised.

Uint8Array
,
) {
const
const id: string
id
=
var Array: ArrayConstructor
Array
.
ArrayConstructor.from<number, string>(iterable: Iterable<number> | ArrayLike<number>, mapfn: (v: number, k: number) => string, thisArg?: any): string[] (+3 overloads)

Creates an array from an iterable object.

@param ― iterable An iterable object to convert to an array.

@param ― mapfn A mapping function to call on every element of the array.

@param ― thisArg Value of 'this' used to invoke the mapfn.

from
(
groupId: Uint8Array<ArrayBufferLike>
groupId
, (
byte: number
byte
) =>
byte: number
byte
.
Number.toString(radix?: number): string

Returns a string representation of an object.

@param ― radix Specifies a radix for converting numeric values to strings. This value is only used for numbers.

toString
(16).
String.padStart(maxLength: number, fillString?: string): string

Pads the current string with a given string (possibly repeated) so that the resulting string reaches a given length. The padding is applied from the start (left) of the current string.

@param ― maxLength The length of the resulting string once the current string has been padded. If this parameter is smaller than the current string's length, the current string will be returned as it is.

@param ― fillString The string to pad the current string with. If this string is too long, it will be truncated and the left-most part will be applied. The default value for this parameter is " " (U+0020).

padStart
(2, "0"),
).
Array<string>.join(separator?: string): string

Adds all the elements of an array into a string, separated by the specified separator string.

@param ― separator A string used to separate one element of the array from the next in the resulting string. If omitted, the array elements are separated with a comma.

join
("");
const
const conversation: Conversation | undefined
conversation
= await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.getById(id: ConversationId): Promise<Conversation | undefined>
getById
(
const id: string
id
);
return
const conversation: Conversation | undefined
conversation
? await
const conversation: Conversation
conversation
.
function state(): Promise<GroupState> | Promise<ConversationState>
state
() :
var undefined
undefined
;
}

message.received carries message and sender IDs, not decoded content. Use message lookup to read the content. conversation.metadata_changed.changed names the changed fields or components. Read conversation.state() for the current values. That state can already contain later changes by the time the handler reads it.

Consent filters do not apply to events. A denied conversation can still produce events. Check consent before showing a notification or a conversation. Device sync conversations are hidden from client events.

The table gives each JavaScript kind, its payload member, and its main values. The same kinds and payload records are available through the native enums. For example, message.status_changed is EventKind.MESSAGE_STATUS_CHANGED on Kotlin and .messageStatusChanged on Swift. Kotlin variants use names such as ClientEvent.MessageStatusChanged; Swift cases use .messageStatusChanged.

KindJavaScript payload memberChange and payload
conversation.joinedconversation_joinedConversation created, joined, replaced, or activated after restoration. group_id, conversation_type, origin, optional adder_inbox_id.
conversation.removedconversation_removedThis installation removed from a group. group_id, cause (removed or left).
conversation.membership_changedmembership_changedInbox members added or removed. group_id, added_inbox_ids, removed_inbox_ids.
conversation.metadata_changedmetadata_changedMetadata, admins, or permissions changed. group_id, changed.
conversation.pausedconversation_pausedConversation paused for a protocol version. group_id, floor.
message.receivedmessage_receivedMessage stored from another installation. group_id, message_id, optional content_type, sender_inbox_id.
message.status_changedmessage_status_changedSent message status changed. group_id, message_id, previous, current.
message.deletedmessage_deletedMessage deleted. group_id, message_id, cause (deleted or deleted_locally).
message.expiredmessage_expiredCleanup deleted an expired message. group_id, message_id. This can occur after its expiry time.
consent.changedconsent_changedStored consent state changed. entity_kind, entity, state.
hmac_keys.updatedhmac_keys_updatedHMAC root key changed or the open client observed an HMAC epoch increase. Empty payload; read current HMAC keys again.
identity.registeredidentity_registeredThis installation completed registration. inbox_id, installation_key.
identity.own_installation_addedown_installation_addedAn installation added to the own inbox. installation_key.
identity.own_installation_revokedown_installation_revokedAn installation revoked from the own inbox. installation_key, is_this_installation.
client.rejected_by_serverrejected_by_serverBackend mismatch or client version too old. cause, optional min_libxmtp_version.
client.lockout_changedlockout_changedCredential lockout entered or left. change.
conversation.fork_detectedconversation_fork_detectedConversation became forked. group_id.
notifications.failednotifications_failedNotification state failed. cause. Browser has no notification subscription API.
archive.restoredarchive_restoredArchive import ended after storing data. complete is false if import failed. Individual imported conversations, messages, and consent records do not emit their usual events.
connection.state_changedconnection_state_changedAggregate connection state of app streams changed. previous, current. States are connecting, connected, reconnecting, failed, or closed.
attachment.upload_startedattachment_upload_startedPending upload started. attachment_key, url, content_digest.
attachment.upload_completedattachment_upload_completedUpload completed. attachment_key, url, content_digest.
attachment.upload_failedattachment_upload_failedUpload failed. attachment_key, url, content_digest, cause.
attachment.download_startedattachment_download_startedDownload started. attachment_key, url, content_digest.
attachment.download_completedattachment_download_completedDownload produced a readable plaintext file. attachment_key, url, content_digest.
attachment.download_failedattachment_download_failedStarted download failed. attachment_key, url, content_digest, cause.
attachment.deletedattachment_deletedLocal attachment data deleted. attachment_key, url, content_digest.
handle.changedhandle_changedAn inbox’s current handle changed: the client stored a newer identity state for it. inbox_id, handle. The first state stored emits none.
laggedlaggedThis subscription discarded events. discarded is a bigint in JavaScript, ULong in Kotlin, and UInt64 in Swift.

conversation_type is group or dm. Join origin is created or welcomed; adder_inbox_id is present for a Welcome. Consent entity_kind is conversation or inbox, and entity is its hex ID. The content type filter and received-message payload use authority_id, type_id, and version_major.

Connection state covers app streams with network interest. Internal background receivers do not count. The state can be closed while background work still uses the network. Server rejection keeps event subscriptions open so they can report the rejection.

FilterJavaScriptKotlin and SwiftEffect
KindskindskindsRequired list. An empty list selects no ordinary events.
Groupsgroup_idsgroupIdsOptional byte IDs. Absent means every group. An empty list selects no group events. Events with no group still pass.
Content typescontent_typescontentTypesOptional list; applies only to message.received. Matches authority, type, and major version. An empty list selects no received messages.
References to own messagesreferences_own_messagesreferencesOwnMessagesDefaults to false. When true, received-message events must be replies or v2 reactions that reference a locally stored message sent by the own inbox.

A group filter also selects the other groups of a stitched DM. A conversation consent event is subject to the group filter; an inbox consent event is not. Filters run before queueing. They do not affect the client’s processing or message-stream delivery. lagged always reaches the subscription, even if its kind was not selected.

client.events(filter) provides an async iterator on Browser, Node, and Agent, a Flow<ClientEvent> on Kotlin, and an SDKEventStream async sequence on Swift. Create one iterator or collector for each subscription. Register separate subscriptions when separate consumers need the same event.

export async function
function readEvents(client: Client, handleStatus: (change: MessageStatusChanged) => Promise<void>, refreshAll: (discarded: bigint) => Promise<void>): Promise<void>
readEvents
(
client: Client
client
:
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
,
handleStatus: (change: MessageStatusChanged) => Promise<void>
handleStatus
: (
change: MessageStatusChanged
change
:
type MessageStatusChanged = {
readonly group_id: Uint8Array;
readonly message_id: Uint8Array;
readonly previous: EventMessageStatus;
readonly current: EventMessageStatus;
}
MessageStatusChanged
) =>
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void>,
refreshAll: (discarded: bigint) => Promise<void>
refreshAll
: (
discarded: bigint
discarded
: bigint) =>
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void>,
) {
const
const stream: EventStream
stream
= await
client: Client
client
.
Client.events(filter: EventFilter): Promise<EventStream>

A stream of the client events that filter selects.

events
({
kinds: EventKind[]
kinds
: ["message.status_changed"] });
try {
for await (const
const event: ClientEvent
event
of
const stream: EventStream
stream
) {
if (
const event: ClientEvent
event
.
kind: "conversation.joined" | "conversation.removed" | "conversation.membership_changed" | "conversation.metadata_changed" | "conversation.paused" | "message.received" | "message.status_changed" | "message.deleted" | "message.expired" | "consent.changed" | "hmac_keys.updated" | "identity.registered" | "identity.own_installation_added" | "identity.own_installation_revoked" | "client.rejected_by_server" | "client.lockout_changed" | "conversation.fork_detected" | "notifications.failed" | "archive.restored" | "connection.state_changed" | ... 8 more ... | "lagged"
kind
=== "message.status_changed") {
await
handleStatus: (change: MessageStatusChanged) => Promise<void>
handleStatus
(
const event: {
readonly kind: "message.status_changed";
readonly message_status_changed: MessageStatusChanged;
}
event
.
message_status_changed: MessageStatusChanged
message_status_changed
);
} else if (
const event: {
readonly kind: "conversation.joined";
readonly conversation_joined: ConversationJoined;
} | {
readonly kind: "conversation.removed";
readonly conversation_removed: ConversationRemoved;
} | {
readonly kind: "conversation.membership_changed";
readonly membership_changed: MembershipChanged;
} | {
readonly kind: "conversation.metadata_changed";
readonly metadata_changed: MetadataChanged;
} | ... 23 more ... | {
...;
}
event
.
kind: "conversation.joined" | "conversation.removed" | "conversation.membership_changed" | "conversation.metadata_changed" | "conversation.paused" | "message.received" | "message.deleted" | "message.expired" | "consent.changed" | "hmac_keys.updated" | "identity.registered" | "identity.own_installation_added" | "identity.own_installation_revoked" | "client.rejected_by_server" | "client.lockout_changed" | "conversation.fork_detected" | "notifications.failed" | "archive.restored" | "connection.state_changed" | "attachment.upload_started" | ... 7 more ... | "lagged"
kind
=== "lagged") {
await
refreshAll: (discarded: bigint) => Promise<void>
refreshAll
(
const event: {
...;
}
event
.
lagged: Lagged
lagged
.
discarded: bigint
discarded
);
}
}
} finally {
await
const stream: EventStream
stream
.
EventStream.return(): Promise<IteratorResult<ClientEvent>>
return
();
}
}

Breaking a JavaScript for await loop calls the iterator’s return() method. You can also call await stream.return() to end a pending read. A handler error in an iterator loop or Flow collection leaves that loop; catch the error if the app must keep reading. This differs from callback error handling below.

The SDK awaits one callback at a time for each listener. Return or await the work that must complete before the next callback starts. A thrown error or rejected promise in a callback does not stop later callbacks. Catch errors in your handler if your app needs to record or retry that work. The SDK does not replay the failed event.

A callback can read from the same client, stop its listener, or end the client. Stopping prevents later callback starts. It does not wait for a callback that has already started. Give any app work started by that callback its own lifetime.

Each subscription holds at most 1024 events, including a running callback. If its queue fills, it discards new events and reports their count in lagged before later events. Read the current state again after lagged. A slow listener does not stop the other listeners.

Events follow change order within a group, consent entity, or attachment key. Changes for separate groups can arrive in either order. Only the client instance that makes a change emits its event. A second client or process that shares the database does not make this client’s listener receive its changes. Shared credential lockout changes are reported by each affected client.

Agent agent.on(...) events have a separate API from agent.client events. They carry typed message or conversation contexts. Use agent.on("text", ...) for text message handling, and agent.client.startListener(...) for state changes such as message status or consent.

export function
function listenToAgentMessages(agent: Agent, onText: (...args: EventHandlerMap<unknown>["text"]) => Promise<void>, onError: (error: Error) => void): () => void
listenToAgentMessages
(
agent: Agent<unknown>
agent
:
class Agent<ContentTypes = unknown>

Event-driven XMTP agent that routes conversations and messages to middleware.

Agent
,
onText: (ctx: MessageContext<string, unknown>) => Promise<void>
onText
: (...
args: [ctx: MessageContext<string, unknown>]
args
:
type EventHandlerMap<ContentTypes> = {
actions: [ctx: MessageContext<Actions, ContentTypes>];
attachment: [ctx: MessageContext<RemoteAttachment, ContentTypes>];
conversation: [ctx: ConversationContext<ContentTypes>];
"group-update": [ctx: MessageContext<GroupUpdated, ContentTypes>];
dm: [ctx: ConversationContext<ContentTypes, Dm>];
... 16 more ...;
"wallet-send-calls": [ctx: MessageContext<WalletSendCalls, ContentTypes>];
}

Event names and handler arguments emitted by an agent.

EventHandlerMap
<unknown>["text"]) =>
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void>,
onError: (error: Error) => void
onError
: (
error: Error
error
:
interface Error
Error
) => void,
) {
agent: Agent<unknown>
agent
.
NodeJS.EventEmitter<EventHandlerMap<unknown>>.on<"text">(eventName: "text" | keyof EventEmitterEventMap | "actions" | "attachment" | "conversation" | "group-update" | "dm" | "group" | "inline-attachment" | "intent" | "leave-request" | "markdown" | "message" | "multi-attachment" | "reaction" | "read-receipt" | "reply" | "start" | "stop" | "transaction-reference" | "unhandledError" | "unknownMessage" | "wallet-send-calls", listener: (ctx: MessageContext<string, unknown>) => void): Agent<unknown>

Adds the listener function to the end of the listeners array for the event named eventName. No checks are made to see if the listener has already been added. Multiple calls passing the same combination of eventName and listener will result in the listener being added, and called, multiple times.

server.on('connection', (stream) => {
console.log('someone connected!');
});

Returns a reference to the EventEmitter, so that calls can be chained.

By default, event listeners are invoked in the order they are added. The emitter.prependListener() method can be used as an alternative to add the event listener to the beginning of the listeners array.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@since ― v0.1.101

@param ― eventName The name of the event.

@param ― listener The callback function

on
("text",
onText: (ctx: MessageContext<string, unknown>) => Promise<void>
onText
);
agent: Agent<unknown>
agent
.
NodeJS.EventEmitter<EventHandlerMap<unknown>>.on<"unhandledError">(eventName: "text" | keyof EventEmitterEventMap | "actions" | "attachment" | "conversation" | "group-update" | "dm" | "group" | "inline-attachment" | "intent" | "leave-request" | "markdown" | "message" | "multi-attachment" | "reaction" | "read-receipt" | "reply" | "start" | "stop" | "transaction-reference" | "unhandledError" | "unknownMessage" | "wallet-send-calls", listener: (error: Error) => void): Agent<unknown>

Adds the listener function to the end of the listeners array for the event named eventName. No checks are made to see if the listener has already been added. Multiple calls passing the same combination of eventName and listener will result in the listener being added, and called, multiple times.

server.on('connection', (stream) => {
console.log('someone connected!');
});

Returns a reference to the EventEmitter, so that calls can be chained.

By default, event listeners are invoked in the order they are added. The emitter.prependListener() method can be used as an alternative to add the event listener to the beginning of the listeners array.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@since ― v0.1.101

@param ― eventName The name of the event.

@param ― listener The callback function

on
("unhandledError",
onError: (error: Error) => void
onError
);
return () => {
agent: Agent<unknown>
agent
.
NodeJS.EventEmitter<EventHandlerMap<unknown>>.off<"text">(eventName: "text" | keyof EventEmitterEventMap | "actions" | "attachment" | "conversation" | "group-update" | "dm" | "group" | "inline-attachment" | "intent" | "leave-request" | "markdown" | "message" | "multi-attachment" | "reaction" | "read-receipt" | "reply" | "start" | "stop" | "transaction-reference" | "unhandledError" | "unknownMessage" | "wallet-send-calls", listener: (ctx: MessageContext<string, unknown>) => void): Agent<unknown>

Alias for emitter.removeListener().

@since ― v10.0.0

off
("text",
onText: (ctx: MessageContext<string, unknown>) => Promise<void>
onText
);
agent: Agent<unknown>
agent
.
NodeJS.EventEmitter<EventHandlerMap<unknown>>.off<"unhandledError">(eventName: "text" | keyof EventEmitterEventMap | "actions" | "attachment" | "conversation" | "group-update" | "dm" | "group" | "inline-attachment" | "intent" | "leave-request" | "markdown" | "message" | "multi-attachment" | "reaction" | "read-receipt" | "reply" | "start" | "stop" | "transaction-reference" | "unhandledError" | "unknownMessage" | "wallet-send-calls", listener: (error: Error) => void): Agent<unknown>

Alias for emitter.removeListener().

@since ― v10.0.0

off
("unhandledError",
onError: (error: Error) => void
onError
);
};
}

The returned function removes only these Agent handlers. Register message handlers before calling await agent.start(). Use await agent.stop() to stop its stream generation. If your service also owns the client, end it with await agent.client.end() when the service ends.

Agent message and conversation handlers can be async. The Agent awaits their work before accepting delivery. Middleware that returns without next() suppresses message events. The Agent skips messages from its own inbox. Handler failures use Agent error middleware; they do not follow the client-event callback error rule. A terminal stream failure needs an explicit agent.start() to open new streams.

For the separate Agent event catalog and context APIs, see Agent events. For the full client event contract, see Client events.