> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cloak.chat/llms.txt
> Use this file to discover all available pages before exploring further.

# Message history

> Read past messages in a server channel with fetchMessages, using date or message cursors.

Sometimes your bot needs messages it did not see live: the last few in a channel, everything before a date, or the context around a single message. `client.fetchMessages()` reads them back from the server, always ordered oldest to newest. This guide covers the cursors, the paging behavior, and what you get back.

<Note>
  `fetchMessages` reads **server-channel history only**. There is no DM history read in this SDK. A bot sees direct messages as they arrive on `messageCreate` and cannot page back through them. See [Direct messages](/guides/direct-messages).
</Note>

## Read the newest messages

With no cursor, `fetchMessages(serverId, channelId, opts?)` returns the newest messages in a channel, oldest to newest. `limit` defaults to 50.

```ts theme={null}
const recent = await client.fetchMessages(serverId, channelId);
for (const msg of recent) {
  console.log(`${msg.authorId}: ${msg.content}`);
}
```

Reads are auto-paginated for you, so a larger `limit` fetches across as many pages as it takes.

```ts theme={null}
const lastHundred = await client.fetchMessages(serverId, channelId, { limit: 100 });
```

<Info>
  The wire page sizes are fixed: the first page holds up to 50 rows, and each page after it up to 25. A `limit` of 100 therefore costs several round-trips. The read stops early when a short page proves the history is exhausted in that direction.
</Info>

## Cursors are a Date or a Message

You point a read at a moment in time with a cursor. A cursor is always a `Date` or a `Message`, never a message id.

<Note>
  Cloak message ids are random UUIDv4 values, so they do not sort by time. There is no id cursor. When you pass a `Message` as a cursor, the read uses that message's `createdAt`. A `Message` with no `createdAt` throws.
</Note>

Three options place the window relative to the cursor: `before`, `after`, and `around`.

## Page backward with before

`before` returns messages strictly older than the cursor. The newest `limit` of them are kept, and the result is still ordered oldest to newest.

```ts theme={null}
// The 50 messages just before the start of the year.
const older = await client.fetchMessages(serverId, channelId, {
  before: new Date('2026-01-01T00:00:00Z'),
});
```

Pass a `Message` to walk backward from one you already have. This is the usual way to load older history a page at a time.

```ts theme={null}
const page = await client.fetchMessages(serverId, channelId, { limit: 50 });
// Fetch the previous page: everything before the oldest message so far.
const previous = await client.fetchMessages(serverId, channelId, {
  before: page[0],
  limit: 50,
});
```

`before` is auto-paginated.

## Page forward with after

`after` returns messages strictly newer than the cursor. The `limit` closest to the cursor are returned, so the oldest matching messages are the ones you keep.

```ts theme={null}
// The first 50 messages after the bot joined.
const since = await client.fetchMessages(serverId, channelId, {
  after: joinedAt,
  limit: 50,
});
```

`after` is auto-paginated.

## Fetch context around a message

`around` returns a single window of about 50 messages straddling the cursor. Reach for it when you want the context on either side of one message.

```ts theme={null}
const context = await client.fetchMessages(serverId, channelId, {
  around: flaggedMessage,
});
```

<Note>
  `around` is the one read that is not auto-paginated. It returns a single window and ignores `limit` for paging.
</Note>

## Options

<ParamField path="before" type="Date | Message">
  Return messages strictly older than the cursor. Auto-paginated.
</ParamField>

<ParamField path="after" type="Date | Message">
  Return messages strictly newer than the cursor. Auto-paginated.
</ParamField>

<ParamField path="around" type="Date | Message">
  Return a single window of about 50 messages straddling the cursor. Not auto-paginated.
</ParamField>

<ParamField path="limit" type="number" default="50">
  How many messages to keep. Applies to `before`, `after`, and a cursorless read. `around` ignores it for paging.
</ParamField>

<ParamField path="groupId" type="string">
  The channel's group, where you already know it.
</ParamField>

<Info>
  The whole paged read runs as one serialized action, so the channel selection cannot move mid-read. You get a consistent slice even when several pages are fetched under the hood.
</Info>

## Returned messages are live

Every message from `fetchMessages` is fully actionable, exactly like one you received on the firehose. Each carries `reply`, `channel.send`, `react`, `unreact`, `edit`, `delete`, `pin`, and `unpin`, plus a `pinned` boolean.

The same fields are populated too. `mentions`, `mentionsMe`, and `repliedTo` are computed for history exactly as they are for a live message, because both paths run through the same builder.

```ts theme={null}
const recent = await client.fetchMessages(serverId, channelId, { limit: 20 });
for (const msg of recent) {
  if (msg.authorId === client.user?.id) continue; // skip our own messages
  if (msg.mentionsMe) console.log(`missed a ping from ${msg.authorId}`);
  if (!msg.pinned && msg.content.includes('important')) {
    await msg.pin();
  }
}
```

History rows always carry a **non-null `serverId`**, because this read is server-channel only. That is why the loop above can call `msg.pin()` without a guard. A live `messageCreate` message is different: `serverId` is `null` for a DM, and the mutation methods reject there. Guard with `if (msg.isDM || !msg.serverId) return;` before you carry this pattern into a `messageCreate` handler.

<Warning>
  `Message.command` is **never set on fetched history**. Replaying history must not re-execute old slash commands, so the SDK deliberately leaves the field undefined on this path. If you want to audit past invocations, parse the text yourself.
</Warning>

See the [Message type](/api-reference/types#message) for the full shape.

## Undecryptable messages

Each message decrypts with the key for its own epoch. If a row cannot be decrypted, its `content` is the empty string `''`. You never receive raw ciphertext, so a simple check is enough to skip a message you cannot read.

```ts theme={null}
const history = await client.fetchMessages(serverId, channelId);
const readable = history.filter((msg) => msg.content !== '');
```

A bot only holds keys from the epochs it was present for, so history from before it joined, or from before a key cycle, commonly reads as `''`.

## Requires the message\_read\_history permission

`fetchMessages` requires the `message_read_history` permission. If the server has not granted it, the promise rejects with a `CloakActionError` whose `.permission` is `'message_read_history'`.

```ts theme={null}
import { CloakActionError } from '@cloak-software/bot-sdk';

try {
  const history = await client.fetchMessages(serverId, channelId);
  console.log(`read ${history.length} messages`);
} catch (err) {
  if (err instanceof CloakActionError && err.permission === 'message_read_history') {
    console.warn('This server has not granted history access.');
  }
}
```

<Tip>
  You can pre-gate the call with `client.can('message_read_history', serverId)` to skip it when the grant is missing. The check is advisory, and the server stays the authority. See [Permissions](/concepts/permissions).
</Tip>

## Find where a message lives

`client.messageLocation(messageId)` tells you where a message the bot has seen lives, whether it arrived live or through `fetchMessages`. It returns a `MessageLocation`, or `undefined` when the message is not in the cache.

```ts theme={null}
const where = client.messageLocation(someMessageId);
if (where?.serverId) {
  console.log(`message is in ${where.serverId} / ${where.channelId}`);
} else if (where) {
  console.log(`message is in dm ${where.channelId}`);
}
```

<ResponseField name="serverId" type="string | null">
  The server the message lives in, or `null` when the remembered message is a direct message. In that case `channelId` holds the dm id and is the whole location.
</ResponseField>

<ResponseField name="channelId" type="string">
  The channel within that server, or the dm id when `serverId` is `null`.
</ResponseField>

<ResponseField name="groupId" type="string">
  The channel's group, where known.
</ResponseField>

<ResponseField name="authorId" type="string">
  The id of the message's author.
</ResponseField>

<ResponseField name="createdAtMs" type="number | null">
  When the message was created, in milliseconds, or `null` if unknown.
</ResponseField>

<Note>
  The lookup is a bounded, best-effort cache of about the last 5000 messages the bot has seen. Older messages fall out, and `messageLocation` returns `undefined` for them.
</Note>

## Next

<CardGroup cols={2}>
  <Card title="The Message object" icon="message" href="/api-reference/types#message">
    Everything a returned message can do.
  </Card>

  <Card title="Direct messages" icon="envelope" href="/guides/direct-messages">
    Why DM history is out of scope, and what you get instead.
  </Card>

  <Card title="Mentions and replies" icon="at" href="/guides/mentions-and-replies">
    How mentions and mentionsMe are derived.
  </Card>

  <Card title="Permissions" icon="key" href="/concepts/permissions">
    What message\_read\_history grants and how.
  </Card>
</CardGroup>
