# Forking Agent Channels

Fork copies an [`agent` channel](https://getstream.io/chat/docs/node/channel-features/#the-agent-channel-type) up to one of its top-level messages into a new agent channel. The fork starts with the conversation as a client sees it up to that message, and both channels then continue on their own. The source channel is not changed. This is how an agent backend lets a user branch a conversation into a new chat.

A fork is a copy, not a reference: once copied, its messages stay even if the source is later rewound, truncated or deleted. Attachments are the exception, see below. To branch inside one channel instead, see [Rewinding Agent Channels](https://getstream.io/chat/docs/node/agent-channel-rewind/).

**Fork is in beta.** It works on `agent` channels only, and Stream enables it per app: [contact support](https://getstream.io/contact/support/) to turn it on. Until it is enabled, both fork endpoints return `403`.

## Who can fork

Fork is a server-side operation. It needs the `ForkChannel` permission on the source, which only server-side requests and users with the `admin` role have, and `CreateChannel` for the new channel. Users and guests cannot fork an agent channel.

On a server-side request, `user_id` names the user who forks the channel, who becomes the fork's creator. Without it, the creator defaults to the source's.

## Fork a channel

Call the endpoint on the source channel:

| Field                 | Required | Description                                                                                                                                                                      |
| --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message_id`          | Yes      | The top-level message to fork at. It must be a regular or system message of the source, visible and not deleted. The fork copies the messages created up to it, itself included. |
| `channel.id`          | Yes      | The ID of the new agent channel. It must not exist yet.                                                                                                                          |
| `channel.data`        | No       | Data of the new channel, as on channel creation: name, image, custom fields and so on. Members go in `channel.members`, not here. The team defaults to the source's.             |
| `channel.members`     | No       | Up to 100 members of the new channel. Defaults to the source's members. A source with more than 100 members must name them.                                                      |
| `exclude_attachments` | No       | When `true`, the copied messages carry no attachments. Defaults to `false`.                                                                                                      |
| `user_id`             | No       | Server-side only. The user who forks the channel.                                                                                                                                |

```ruby label="Ruby"
require 'getstream_ruby'
Models = GetStream::Generated::Models

response = client.chat.fork_channel('agent', 'conversation-1', Models::ForkChannelRequest.new(
  message_id: 'answer-3',
  channel: Models::ForkChannelTarget.new(id: 'conversation-1-branch'),
  user_id: 'alice'
))
puts response.fork.status
puts response.fork.message_id_map.to_h
```

```bash label="cURL"
curl -X POST "https://chat.stream-io-api.com/channels/agent/conversation-1/fork?api_key=${API_KEY}" \
  -H "Authorization: ${SERVER_TOKEN}" \
  -H "stream-auth-type: jwt" \
  -H "Content-Type: application/json" \
  -d '{"message_id": "answer-3", "channel": {"id": "conversation-1-branch"}, "user_id": "alice"}'
```

Fork only at a message the agent has finished writing. Stream does not know whether an answer is still being streamed into a message, so a fork at such a message copies it as it is at that moment.

A retry with the same `channel.id` and `message_id` returns the existing fork instead of failing, so you can safely retry a request that timed out.

## The response

The response is the fork's channel state, as a channel query returns it, plus a `fork` object:

```json
{
  "channel": { "cid": "agent:conversation-1-branch", "...": "..." },
  "messages": ["..."],
  "members": ["..."],
  "fork": {
    "channel_cid": "agent:conversation-1-branch",
    "source_channel_cid": "agent:conversation-1",
    "source_message_id": "answer-3",
    "status": "backfilling",
    "copied_message_count": 0,
    "task_id": "...",
    "message_id_map": {
      "answer-3": "4f0c2e8a-6b1d-5c3e-9a7f-1d2b3c4e5f60",
      "prompt-3": "a2b9d7c1-0e4f-5a6b-8c9d-7e1f2a3b4c5d"
    },
    "created_at": "2026-11-03T10:20:05.311Z"
  },
  "duration": "64.03ms"
}
```

| Field                      | Description                                                                                                                                                                                                  |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `status`                   | `completed` once every message is copied, `backfilling` while the older history is still being copied, `failed` when the copy failed for good (see [Failed forks](#failed-forks)).                           |
| `copied_message_count`     | The number of messages copied, set once the fork is completed. On a failed fork, the number copied before the failure.                                                                                       |
| `truncated`                | `true` when the source held more than 10,000 messages up to the fork point. The fork then holds the newest 10,000. Omitted otherwise.                                                                        |
| `oldest_source_message_id` | On a truncated fork, the source message ID of the oldest message it copied. Omitted otherwise.                                                                                                               |
| `failed_reason`            | On a failed fork, why it failed: `fork_message_id_taken` or `message_too_large`.                                                                                                                             |
| `task_id`                  | On a backfilling fork, the task copying the older history. [`GET /tasks/{task_id}`](https://getstream.io/docs/platform/async-operations/) reports its progress.                                                                  |
| `message_id_map`           | Maps the source ID of each message the request copied to its ID in the fork. Only the fork endpoint returns it. Use the [message ID lookup](#find-a-copied-message) for messages the background task copied. |

The fork's channel object carries the same summary, without `message_id_map`, as `channel.fork`. It is there wherever the channel is returned: the fork response, getting or querying the channel, and Query Channels. A channel that is not a fork has no `fork` field.

## How the history is copied

The request copies the newest page of the conversation: up to 100 top-level messages up to the fork point and up to 100 of their replies. The fork is usable when the request returns, and an agent that reads the latest history finds it there.

When the conversation is longer, a background task copies the older history, newest first, and `fork.status` is `backfilling` until it is done. While a fork is backfilling, an empty older page does not mean that the conversation started there: the older messages are still on the way. Wait for `channel.forked` before treating the history as complete.

What is copied:

- The top-level messages a client sees up to and including the fork point, and their replies up to it. A rewind or truncation of the source applies: hidden and truncated messages are not copied.
- Deleted, shadowed, ephemeral and error messages are left out, and so are replies whose parent is not copied.
- Each copy keeps its text, HTML, MML, command, type, author, `created_at`, `updated_at`, custom data, mentions, restricted visibility, `silent` flag, translations and, unless `exclude_attachments` is set, its attachments. Attachments are copied as they are, URLs included, so a copied attachment points at the same uploaded file as the source's. During the beta, that file is not guaranteed to stay available once the source channel is deleted. If the fork must stand on its own, set `exclude_attachments: true`.
- `parent_id` and `quoted_message_id` point to the copies. A quote of a message that was not copied returns no quoted message, as a quote of a deleted message does.
- Threads are copied with their replies, so Query Threads lists them in the fork.

What is not copied: reactions, pins, polls, read state, mutes, reminders, moderation flags and edit history.

Copied messages keep their `created_at`, which is before the members joined the fork, so they are not unread. Copying sends no `message.new` event and no push notification.

### Limits

- **10,000 messages.** A fork copies at most the newest 10,000 messages of the source's history up to the fork point. The fork is then marked `truncated`, and `oldest_source_message_id` names the oldest message it holds. The older history stays in the source only.
- **100 forks per source.** A channel can have at most 100 forks that did not fail. Failed forks do not count. Hard-deleting a fork frees its slot.

## Find a copied message

Each copy has a new ID, derived from the fork's CID and the source message ID, so the same source message always maps to the same copy. Agent backends that key tool calls or context on message IDs can map source IDs to fork IDs with the lookup endpoint. Call it on the fork with up to 1,000 source message IDs. Source messages the fork holds no copy of are left out of the map.

The lookup only needs read access to the fork, so a member can call it client-side too.

```ruby label="Ruby"
response = client.chat.fork_message_i_ds('agent', 'conversation-1-branch', Models::ForkMessageIDsRequest.new(
  source_message_ids: ['prompt-1', 'answer-1']
))
puts response.message_id_map.to_h
```

```bash label="cURL"
curl -X POST "https://chat.stream-io-api.com/channels/agent/conversation-1-branch/fork/message_ids?api_key=${API_KEY}" \
  -H "Authorization: ${SERVER_TOKEN}" \
  -H "stream-auth-type: jwt" \
  -H "Content-Type: application/json" \
  -d '{"source_message_ids": ["prompt-1", "answer-1"]}'
```

### Message IDs reserved while copying

The ID of a copy is a version 5 UUID. Until a fork is finished, a message sent into it whose ID is a version 5 UUID is rejected with `400` and reason `fork_message_id_reserved`, so that no message can take the ID of a copy still to come. This applies to regular, scheduled and pending messages. Use another ID form, such as a version 4 UUID, or send the message after `channel.forked`. Once the fork is completed or failed, any ID is accepted again.

## Failed forks

Some copy failures would repeat on every retry. The fork is then failed for good instead of staying `backfilling`: `fork.status` is `failed` and `failed_reason` says why.

| `failed_reason`         | Cause                                                                                                                                        |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `fork_message_id_taken` | Another message already holds the ID a source message is copied under. Copy IDs depend on the fork's CID, so fork into another `channel.id`. |
| `message_too_large`     | A source message is over a size limit of agent channels: its text, custom data or attachments.                                               |

The fork request answers a failed fork with `200` and the failed summary, not with an error. A failed fork keeps the messages it copied and is an ordinary agent channel: you can use it, rewind it and fork it. Nothing more is copied into it, a retry returns it as it is, and it does not count toward the source's limit of 100 forks.

## Forks and rewinds

- **Forking a rewound channel** copies only the visible messages. The fork starts with no hidden messages of its own.
- **Rewinding the source while a fork backfills** hides the rewound messages from the pages not copied yet. Pages already copied stay in the fork.
- **Rewinding or forking a fork** works once its copy is finished. While it is still backfilling, both fail with `409` and reason `fork_in_progress`.

## Events

| Event                           | Sent to                                                                                                                       | Description                                                                                                                                                                       |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channel.created`               | Clients watching the fork, and webhooks                                                                                       | Sent once, when the fork is created.                                                                                                                                              |
| `notification.added_to_channel` | The fork's members                                                                                                            | Sent once, when the fork is created.                                                                                                                                              |
| `channel.forked`                | On the source: clients watching it, and webhooks. On the fork: clients watching it and its first 100 members, WebSocket only. | Sent once, when the fork is finished, completed or failed. Carries `channel`, `fork` (without `message_id_map`) and `user`. `cid` tells which of the two channels it was sent on. |

A client showing a fork while it was backfilling should reload it when `channel.forked` arrives. A user who is a member of both channels receives both events. If the source was deleted before the fork finished, the fork's `channel.forked` also goes to webhooks.

`channel.forked` is never sent as a push notification.

## Errors

| HTTP  | Code | `exception_fields.reason`  | When                                                                                                                                                            |
| ----- | ---- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `403` | 17   |                            | Fork is not enabled for the app, or the caller does not have the `ForkChannel` or `CreateChannel` permission.                                                   |
| `400` | 4    |                            | The source is not an `agent` channel, or `message_id` is not a visible top-level regular or system message of the source.                                       |
| `400` | 4    | `fork_target_exists`       | `channel.id` names a channel that already exists and is not this fork.                                                                                          |
| `400` | 130  | `too_many_forks`           | The source already has 100 forks that did not fail. `exception_fields.limit` carries the limit.                                                                 |
| `400` | 4    | `fork_message_id_reserved` | A message sent into a fork that is still copying has a version 5 UUID as its ID. See [Message IDs reserved while copying](#message-ids-reserved-while-copying). |
| `409` | 127  | `fork_point_ambiguous`     | Another visible message has the same `created_at` as `message_id`, so the two cannot be told apart.                                                             |
| `409` | 129  | `fork_target_conflict`     | `channel.id` is already a fork of another message. `exception_fields.source_channel_cid` and `exception_fields.source_message_id` say which.                    |
| `409` | 131  | `fork_in_progress`         | The source is itself a fork whose history is still being copied. Fork it once it is finished.                                                                   |

Code 128 used to be `fork_too_large`. It is no longer returned: a fork of a longer conversation copies the newest 10,000 messages and is marked `truncated` instead.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/node/agent-channel-fork/](https://getstream.io/chat/docs/node/agent-channel-fork/).