microsoft/channels-migration
>- Migrate code that talks to or implements the Agent Host Protocol from the pre-channels model to the current channel-based model. Use when asked to `envelope` field inside `action` params, a `resource` field on subscribe/unsubscribe/snapshot results, or `session` / `terminal` / `changeset` fields on command params or action payloads.
npx skills add https://github.com/microsoft/agent-host-protocol --skill channels-migration
This skill helps you migrate a codebase that consumes (or implements) the
Agent Host Protocol from the pre-channels model to the **channel-based
model** introduced by RFC #117. The migration was folded into the protocol
as a single breaking step alongside the 0.2.0 version bump (which also
introduced changesets and the session/customizationUpdated action). The
combined wire format on 0.2.0 is what this skill targets; there is no
transitional version.
The migration is mechanical in most places but touches several shapes at once,
so do it in passes rather than file-by-file. The order below minimises the
chance of leaving the codebase in a half-broken state.
means and why fields moved.
enough that you can land it as its own commit.
updating in your codebase.
repo's types/*.ts (state.ts, actions.ts, commands.ts,
messages.ts, notifications.ts) or in docs/specification/.
In the pre-channels world, AHP had a privileged "state subscription"
mechanism: clients subscribed to a URI, got a snapshot, and received action
envelopes for that URI's state. Notifications (session catalogue events,
auth-required) were stuffed inside a single notification wrapper method.
The URI of a state-bearing resource was embedded directly into the action
payload (action.session, action.terminal).
In the channel-based world, every push-style interaction lives on a
channel, identified by a URI. Subscriptions, action delivery, and
protocol notifications all carry a top-level channel: URI field that
identifies which subscription a message belongs to. A channel MAY have
associated state (root, sessions, terminals), or it MAY be stateless (future
use: logging, MCP, LSP relay). The wire shape is uniform across types.
Concretely:
agenthost:/root → ahp-root://copilot:/<uuid>) →ahp-session:/<uuid> in docs/examples. The provider lives on
SessionSummary.provider, not in the URI. (Live session URIs are
announced by the server, so this is mainly a docs/example change for
consumers — but type-level shape changes still apply.)
ahp-terminal:/<id>. Server-defined; clients treat as opaque.
ahp-changeset:/<id>. Server-defined; obtained by expanding a Changeset.uriTemplate.
Changesets are a new channel type introduced in the same step.
channel everywhere: subscribe/unsubscribe/action-envelope/dispatchAction/every protocol notification AND every command has
channel: URI at the top level of its params. Commands that are
connection-level rather than channel-scoped (e.g. initialize, ping,
listSessions, the resource* filesystem commands, authenticate)
set channel to the literal 'ahp-root://'.
session: URI and terminal: URIfields are gone from individual actions. Routing is by envelope.
notification wrapper andthe ProtocolNotification union are gone.
SessionDiffsChangedAction is gone: replaced bySessionChangesetsChangedAction (catalogue updates on a session) plus a
new family of per-changeset actions (changeset/statusChanged,
changeset/fileSet, changeset/fileRemoved,
changeset/operationsChanged, changeset/cleared) and the
invokeChangesetOperation command. See docs/guide/changesets.md.
Apply these in order. After each pass, run your typecheck/test loop.
agenthost:/root → ahp-root://
This is the one immediately-wire-breaking rename. Any client that subscribes
to root state with the old URI will receive an error from a channels-era
server.
Update every literal occurrence in code, tests, fixtures, configuration, and
documentation. Search patterns:
agenthost:/rootAHP_ROOT_URIor similar constant.
subscribe / unsubscribe params: resource → channel// before
{ method: 'subscribe', params: { resource: <uri> } }
{ method: 'unsubscribe', params: { resource: <uri> } }
// after
{ method: 'subscribe', params: { channel: <uri> } }
{ method: 'unsubscribe', params: { channel: <uri> } }
SubscribeParams.resource is now SubscribeParams.channel.
UnsubscribeParams.resource is now UnsubscribeParams.channel. The URI
value is unchanged for state channels — only the field name moves.
SubscribeResult becomes { snapshot? }The result of a subscribe request was a flat snapshot. It is now wrapped
in an optional snapshot field, because stateless channels return no
snapshot.
// before — SubscribeResult was the snapshot directly
{
resource: 'ahp-session:/<uuid>',
state: { ... },
fromSeq: 5,
}
// after — SubscribeResult is { snapshot?: Snapshot }
{
snapshot: {
resource: 'ahp-session:/<uuid>',
state: { ... },
fromSeq: 5,
}
}
For stateless channels, snapshot is omitted entirely:
// stateless channel: subscribe returns an empty result
{}
Update every place that destructures the subscribe response. If your client
threaded the snapshot directly into reducers, you now need to read
result.snapshot and handle undefined (stateless) as a separate path.
channel; action payloads lose theirsThe envelope grew a top-level channel field. Every individual session and
terminal action lost its inner channel-identifier field.
// before — ActionEnvelope
{
action: { type: 'session/delta', session: 'copilot:/<uuid>', turnId: 't1', ... },
serverSeq: 6,
origin: { ... },
}
// after — ActionEnvelope
{
channel: 'ahp-session:/<uuid>',
action: { type: 'session/delta', turnId: 't1', ... }, // no `session` field
serverSeq: 6,
origin: { ... },
}
Producer-side changes (anything that builds action envelopes or
individual actions): stop populating session/terminal on action payloads.
Populate envelope.channel instead. The two are typically the same URI you
were already using.
Consumer-side changes (anything that routes actions to per-session or
per-terminal reducers): switch your dispatch keying from
action.session / action.terminal to envelope.channel. The reducer
selection becomes "look at the envelope's channel, route by URI scheme".
Action interfaces that lost their channel field — full list:
SessionReadyAction, SessionCreationFailedAction,SessionTurnStartedAction, SessionDeltaAction,
SessionResponsePartAction, SessionToolCallStartAction,
SessionToolCallDeltaAction, SessionToolCallReadyAction,
SessionToolCallConfirmedAction, SessionToolCallCompleteAction,
SessionToolCallResultConfirmedAction,
SessionToolCallContentChangedAction, SessionTurnCompleteAction,
SessionTurnCancelledAction, SessionErrorAction,
SessionTitleChangedAction, SessionUsageAction,
SessionReasoningAction, SessionModelChangedAction,
SessionServerToolsChangedAction,
SessionActiveClientChangedAction,
SessionActiveClientToolsChangedAction,
SessionPendingMessageSetAction,
SessionPendingMessageRemovedAction,
SessionQueuedMessagesReorderedAction,
SessionInputRequestedAction, SessionInputAnswerChangedAction,
SessionInputCompletedAction, SessionCustomizationsChangedAction,
SessionCustomizationToggledAction,
SessionCustomizationUpdatedAction, SessionTruncatedAction,
SessionIsReadChangedAction, SessionIsArchivedChangedAction,
SessionActivityChangedAction,
SessionChangesetsChangedAction (replaces the removed
SessionDiffsChangedAction),
SessionConfigChangedAction, SessionMetaChangedAction.
session because ToolCallActionBase nolonger carries it. turnId and toolCallId remain.
TerminalDataAction, TerminalInputAction,TerminalResizedAction, TerminalClaimedAction,
TerminalTitleChangedAction, TerminalCwdChangedAction,
TerminalExitedAction, TerminalClearedAction,
TerminalCommandDetectionAvailableAction,
TerminalCommandExecutedAction, TerminalCommandFinishedAction.
0.2.0):ChangesetStatusChangedAction, ChangesetFileSetAction,
ChangesetFileRemovedAction, ChangesetOperationsChangedAction,
ChangesetClearedAction. These never carried a changeset: URI field
in shipping code — it was removed before they were released.
action server notification: drop the envelope wrapperThe server → client action method previously wrapped its envelope:
// before
{ method: 'action', params: { envelope: ActionEnvelope } }
// after
{ method: 'action', params: ActionEnvelope } // envelope is the params, flat
Wherever you build or parse action notifications, remove the extra
{ envelope: ... } nesting.
dispatchAction gains a channelClient → server still uses the dispatchAction method name (this did not
get renamed to action). The params type adds a top-level channel:
// before
{
method: 'dispatchAction',
params: { clientSeq: 1, action: { type: 'session/turnStarted', session: '...', ... } }
}
// after
{
method: 'dispatchAction',
params: {
channel: 'ahp-session:/<uuid>',
clientSeq: 1,
action: { type: 'session/turnStarted', turnId: '...', ... } // no `session`
}
}
The notification wrapper method is gone. Each protocol notification is
now its own top-level JSON-RPC method with its own params type, and each
carries a top-level channel.
| Pre-channels | Post-channels (method) | Params type |
|------------------------------------------------------|---------------------------------|------------------------------|
| notification → notify/sessionAdded | root/sessionAdded | SessionAddedParams |
| notification → notify/sessionRemoved | root/sessionRemoved | SessionRemovedParams |
| notification → notify/sessionSummaryChanged | root/sessionSummaryChanged | SessionSummaryChangedParams|
| notification → notify/authRequired | auth/required | AuthRequiredParams |
Each new params type has channel: URI as its top-level field. For
root/* notifications, channel is ahp-root://. For auth/required,
channel can be any channel the auth requirement is scoped to.
// before
{
method: 'notification',
params: {
notification: {
type: 'notify/sessionAdded',
summary: { resource: 'copilot:/<uuid>', ... }
}
}
}
// after
{
method: 'root/sessionAdded',
params: {
channel: 'ahp-root://',
summary: { resource: 'ahp-session:/<uuid>', ... }
}
}
Drop any code that imports ProtocolNotification, the NotificationType
enum, NotificationMethodParams, or the combined NotificationMap — these
types were removed. Replace them with the per-method params types and a
direct lookup on the wire-level method name. The type-level constraint
that every entry in ClientNotificationMap / ServerNotificationMap has
params extends { channel: URI } is enforced in
types/version/message-checks.ts, alongside an equivalent check that
every entry in CommandMap / ServerCommandMap has
params extends BaseParams (see Pass 10).
If your code constructs session URIs via a helper like
AgentSession.uri(provider, rawId), the provider component is no longer
encoded in the scheme. Update the helper to produce ahp-session:/<rawId>
and remove any AgentSession.provider(session) lookups that extracted the
provider from the URI. Read the provider from SessionSummary.provider
instead.
For session URIs that are announced by an existing AHP server you talk
to, you do not need to change anything in your client beyond accepting the
new scheme. Treat session URIs as opaque strings except where you
specifically construct them.
reconnect still carries subscriptions: URI[]. State-channel replay
behaviour is unchanged. Stateless channels (where they exist) are
re-subscribed on reconnect — missed messages are dropped, never replayed.
If you implement a server: do not include stateless channels in the
replay result's actions list. They will simply re-subscribe.
channel: URIEvery command's params now extends BaseParams { channel: URI }. The
channel field tells the server which channel the command targets, so a
router can dispatch any incoming message — request, response, or
notification — by inspecting params.channel without further
deserialisation.
There are two flavours:
Channel-scoped commands — rename the existing
session / terminal / changeset field to channel. The URI value is
unchanged.
| Command | Old field | New field |
|-----------------------------|-----------------------|-----------|
| createSession | session: URI | channel: URI |
| disposeSession | session: URI | channel: URI |
| createTerminal | terminal: URI | channel: URI |
| disposeTerminal | terminal: URI | channel: URI |
| fetchTurns | session: URI | channel: URI |
| completions | session: URI | channel: URI |
| invokeChangesetOperation | changeset: URI | channel: URI |
| subscribe, unsubscribe, dispatchAction | already channel (Passes 2 & 6) | unchanged |
Connection-level commands — narrow channel to the literal
'ahp-root://'. Add the field explicitly; the TS types enforce it.
Methods: initialize, ping, reconnect, listSessions,
authenticate, resolveSessionConfig, sessionConfigCompletions,
resourceRead, resourceWrite, resourceList, resourceCopy,
resourceDelete, resourceMove, resourceRequest.
// before
{ method: 'initialize', params: { protocolVersions: ['0.2.0'], clientId: 'c1' } }
{ method: 'listSessions', params: {} }
{ method: 'createSession', params: { session: 'ahp-session:/<uuid>', provider: 'copilot' } }
{ method: 'fetchTurns', params: { session: 'ahp-session:/<uuid>', limit: 20 } }
// after
{ method: 'initialize', params: { channel: 'ahp-root://', protocolVersions: ['0.2.0'], clientId: 'c1' } }
{ method: 'listSessions', params: { channel: 'ahp-root://' } }
{ method: 'createSession', params: { channel: 'ahp-session:/<uuid>', provider: 'copilot' } }
{ method: 'fetchTurns', params: { channel: 'ahp-session:/<uuid>', limit: 20 } }
The compile-time check _CheckCommandsHaveChannel in
types/version/message-checks.ts verifies that every entry in
CommandMap / ServerCommandMap has params assignable to BaseParams.
If you forget to add channel to a new command's params, the check
fails to compile and points at the missing field.
Run these searches in your codebase. Each pattern is a strong signal that a
migration site still needs attention.
| Pattern | What it indicates |
|--------------------------------------|--------------------|
| agenthost:/root | Old root URI literal (Pass 1) |
| "resource"/resource: near subscribe/unsubscribe/Subscribe | Old subscribe param shape (Pass 2) |
| result.resource / result.state / result.fromSeq right after subscribe | Old flat-snapshot subscribe result (Pass 3) |
| action.session / action.terminal | Reading the channel off the action payload — should be from the envelope (Pass 4) |
| session: inside an action literal | Producer writing pre-channels payload (Pass 4) |
| params.envelope near 'action' | Old action server notification wrapper (Pass 5) |
| dispatchAction without channel | Client dispatch missing channel (Pass 6) |
| 'notification' as a JSON-RPC method name | Old protocol notification wrapper (Pass 7) |
| notify/sessionAdded, notify/sessionRemoved, notify/sessionSummaryChanged, notify/authRequired | Old protocol notification names (Pass 7) |
| ProtocolNotification, NotificationType, NotificationMethodParams, NotificationMap | Removed types (Pass 7) |
| SessionAddedNotification, SessionRemovedNotification, SessionSummaryChangedNotification, AuthRequiredNotification | Old notification interfaces — renamed to *Params (Pass 7) |
| AgentSession.provider, provider extracted from session scheme | Old provider-via-scheme helper (Pass 8) |
| SessionDiffsChangedAction, summary.diffs, session/diffsChanged | Removed in favour of changesets (Pass 4 list) |
| CreateSessionParams\W+session:, DisposeSessionParams\W+session:, CreateTerminalParams\W+terminal:, DisposeTerminalParams\W+terminal:, FetchTurnsParams\W+session:, CompletionsParams\W+session:, InvokeChangesetOperationParams\W+changeset: | Channel-scoped command params still using the old field name (Pass 10) |
| ListSessionsParams(), PingParams(), ResourceReadParams\(uri: (no channel:), or any other command params constructed without channel | Connection-level command missing the channel: 'ahp-root://' literal (Pass 10) |
After the migration, your code should:
ahp-root:// instead of agenthost:/root.{ channel } (not { resource }) to subscribe and unsubscribe.result.snapshot from subscribe responses; toleratesnapshot === undefined for stateless channels.
channel. Nosession / terminal fields inside individual action payloads.
action server notification's params as the envelopeitself, not as { envelope }.
dispatchAction params with a top-level channel.(root/sessionAdded, root/sessionRemoved,
root/sessionSummaryChanged, auth/required) rather than nested
inside notification.
ProtocolNotification, NotificationType,NotificationMethodParams, or NotificationMap.
SessionSummary.provider, not viathe URI scheme.
SessionDiffsChangedAction / summary.diffs references; consumeSessionState.changesets plus the changeset/* action family instead.
channel: URI. Channel-scopedcommands (createSession, disposeSession, createTerminal,
disposeTerminal, fetchTurns, completions,
invokeChangesetOperation) pass the target channel URI;
connection-level commands pass the literal 'ahp-root://'.
When all these are true, your consumer is on the channels model.
For the full normative description of the channel model, consult these
documents in the microsoft/agent-host-protocol repository:
docs/specification/subscriptions.md — Channels & Subscriptions (theframework, including stateless channels and the URI scheme table)
docs/specification/root-channel.md — Root channel state, actions, andprotocol notifications
docs/specification/session-channel.md — Session channel lifecycle,client-action validation, pending-message consumption
docs/specification/terminal-channel.md — Terminal channel data flowand command detection
docs/specification/lifecycle.md — Connection handshake and reconnectiondocs/guide/changesets.md — Changeset channel model, catalogue,per-changeset state, and invokeChangesetOperation
types/actions.ts, types/commands.ts, types/messages.ts,types/notifications.ts — Source-of-truth type definitions
(BaseParams lives in commands.ts)
types/version/message-checks.ts — Compile-time checks that everycommand and notification carries channel: URI
— The RFC that introduced the channel model
Take microsoft/channels-migration from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.