Forking Agent Channels
Fork copies an agent channel 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.
Fork is in beta. It works on agent channels only, and Stream enables it per app: 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. |
$response = $client->forkChannel("agent", "conversation-1", new Models\ForkChannelRequest(
messageID: "answer-3",
channel: new Models\ForkChannelTarget(id: "conversation-1-branch"),
userID: "alice",
));
echo $response->getData()->fork->status;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:
{
"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). |
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} 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 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,silentflag, translations and, unlessexclude_attachmentsis 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, setexclude_attachments: true. parent_idandquoted_message_idpoint 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, andoldest_source_message_idnames 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.
$response = $client->forkMessageIDs("agent", "conversation-1-branch", new Models\ForkMessageIDsRequest(
sourceMessageIds: ["prompt-1", "answer-1"],
));
print_r($response->getData()->messageIDMap);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
409and reasonfork_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. |
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.