microsoft/session-metadata-migration
>- Migrate code that talks to or implements the Agent Host Protocol to the flattened SessionState model. Use when asked to upgrade an AHP client/server/binding and you see a `summary` sub-object on `SessionState` (e.g. `state.summary.title`, `session.summary.modifiedAt`), `model` / `agent` on `SessionState` / `SessionSummary` / `ChatState` / `ChatSummary`, the `session/modelChanged` or `session/agentChanged` actions, numeric `createdAt` / `modifiedAt`, or `model` / `agent` arguments to `createSession` / `createChat`.
npx skills add https://github.com/microsoft/agent-host-protocol --skill session-metadata-migration
This skill helps you migrate a codebase that consumes (or implements) the Agent
Host Protocol to the flattened SessionState model. The change moves all
session metadata directly onto SessionState, makes SessionSummary a
root-channel-only catalog type, relocates model/agent **selection onto each
Message** (with a new per-chat draft), switches the summary timestamps to
ISO-8601 strings, and removes the session/modelChanged /
session/agentChanged actions. It is a single breaking step; there is no
transitional version.
If you are also coming from the pre-channels protocol, run the
ahp-channels-migration skill first — this skill assumes you are already on
the channel-based model.
The migration is mechanical 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 half-broken. After each pass, run your typecheck/test loop.
to land as its own commit.
types/ (channels-session/state.ts, channels-chat/state.ts,
channels-session/actions.ts, channels-chat/actions.ts) and the canonical
reducers (types/channels-session/reducer.ts,
types/channels-chat/reducer.ts), or in docs/specification/.
Previously, SessionState embedded a summary: SessionSummary sub-object, and
the same SessionSummary shape was both the live session metadata *and* the
catalog entry surfaced on the root channel. Model and agent selection was
session-scoped (summary.model / summary.agent, mutated by
session/modelChanged / session/agentChanged), with optional per-chat
overrides on ChatState / ChatSummary.
Now:
SessionState carries its metadata inline. A new SessionMetadata baseinterface (provider, title, status, activity?, project?,
workingDirectory?, annotations?) is extended/inlined directly onto
SessionState. There is no state.summary anymore — read state.title,
state.status, state.activity, etc. directly. ChatState already
denormalized its summary fields this way; SessionState now matches.
SessionSummary is a root-only catalog type. It still exists and is usedby listSessions, root/sessionAdded, and root/sessionSummaryChanged to
keep a cached session list in sync. It shares the SessionMetadata fields
and additionally owns the identity/catalog-only fields: resource,
createdAt, modifiedAt, and changes. It is not embedded in
SessionState. The host updates modifiedAt at its own discretion and
streams it via root/sessionSummaryChanged.
model andagent are removed from SessionState, SessionSummary, ChatState, and
ChatSummary. Selection now lives on the Message: Message.model? and
Message.agent?. A historic message records the selection actually used (so
a client editing/resending it can retain that choice); when a message omits
them, the agent host's default applies.
draft. ChatState.draft?: Message is the user'sin-progress input (text + attachments + its model/agent). The new
client-dispatchable chat/draftChanged action sets or clears it. Clients MAY
periodically (debounced, not eager) sync their local input state into
draft, and SHOULD initialize input UI for an existing chat from any present
draft. draft is state-only and is not mirrored onto ChatSummary.
SessionSummary.createdAt /modifiedAt changed from numbers (epoch millis) to ISO-8601 strings (e.g.
"2025-03-10T18:42:03.123Z"), matching ChatSummary.modifiedAt and the
resource* filesystem mtime/ctime.
session/modelChanged (SessionModelChangedAction)and session/agentChanged (SessionAgentChangedAction) are gone. The
model / agent parameters are removed from createSession and
createChat.
archived/config actions mutate the flat SessionState fields and do not
touch a session modifiedAt (there is none). The chat reducer continues to
stamp ChatState.modifiedAt.
Apply these in order. Examples are TypeScript (the reference client); Rust,
Kotlin, Swift, and Go consumers apply the same shape changes with their own
casing (modified_at, ModifiedAt, etc.).
SessionState reads and writesSessionState.summary is gone. Every state.summary.<field> becomes
state.<field> for the metadata fields (provider, title, status,
activity, project, workingDirectory, annotations).
- const title = session.summary.title;
- const isInProgress = (session.summary.status & SessionStatus.InProgress) !== 0;
- const cwd = session.summary.workingDirectory;
+ const title = session.title;
+ const isInProgress = (session.status & SessionStatus.InProgress) !== 0;
+ const cwd = session.workingDirectory;
When constructing a SessionState, build it flat — do not nest a summary:
const state: SessionState = {
- summary: { resource, provider, title, status, createdAt, modifiedAt },
+ provider,
+ title,
+ status,
lifecycle: SessionLifecycle.Creating,
activeClients: [],
chats: [],
};
Note resource, createdAt, and modifiedAt are not on SessionState —
they live only on the root-channel SessionSummary (Pass 2).
SessionSummary as a root-only catalog typeSessionSummary still exists, but only as the catalog entry on the root
channel. Keep using it for your cached session list fed by listSessions,
root/sessionAdded, and root/sessionSummaryChanged. Do not read it off
SessionState, and do not expect model / agent on it (Pass 4).
If you maintain a session-list cache that merges root/sessionSummaryChanged
deltas, drop the model / agent cases (those fields no longer exist on the
summary) and keep modifiedAt as a string (Pass 3):
if (changes.title !== undefined) merged.title = changes.title;
if (changes.status !== undefined) merged.status = changes.status;
if (changes.modifiedAt !== undefined) merged.modifiedAt = changes.modifiedAt;
- if (changes.model !== undefined) merged.model = changes.model;
if (changes.workingDirectory !== undefined) merged.workingDirectory = changes.workingDirectory;
createdAt / modifiedAt are ISO-8601 stringsSessionSummary.createdAt and modifiedAt are now strings. Update any code
that parses, formats, compares, or arithmetics them.
- summaries.sort((a, b) => b.modifiedAt - a.modifiedAt); // numeric subtraction
+ summaries.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : a.modifiedAt > b.modifiedAt ? -1 : 0));
ISO-8601 strings sort chronologically under lexicographic comparison, so a
plain string compare is correct for "most recently modified". To do date math,
parse with Date.parse(...) / your platform's ISO parser. Producers that
previously emitted Date.now() should emit new Date().toISOString().
Delete every read/write of model / agent on SessionState,
SessionSummary, ChatState, and ChatSummary, and remove all use of the
session/modelChanged and session/agentChanged actions.
change. The selection is whatever the next Message carries (Pass 5), so a
model picker simply updates the chat's draft (Pass 6) or sets model /
agent on the message you are about to send.
SessionModelChanged / SessionAgentChangedcases. Remove model / agent from any ChatSummary partial-update merge
(e.g. the session/chatUpdated handler).
- store.dispatch(session, { type: 'session/modelChanged', model: { id: 'gpt-4o' } });
+ // Keep the picked model in your local input state and attach it to the
+ // outgoing message (Pass 5), or sync it into the chat draft (Pass 6).
MessageMessage gained model?: ModelSelection and agent?: AgentSelection. Set
them when you send a turn or steering/queued message, and read them back from
historic messages when editing/resending.
const message: Message = {
text,
origin: { kind: MessageKind.User },
attachments,
+ model: selectedModel, // omit to use the agent host's default
+ agent: selectedAgent, // omit for no custom agent
};
store.dispatch(chat, { type: 'chat/turnStarted', turnId, message });
When rendering an existing turn, the model/agent it ran with is on
turn.message.model / turn.message.agent (absent ⇒ host default).
draft and chat/draftChangedChatState.draft?: Message holds the user's in-progress input. Wire two
directions:
draft. When you open/show a chat, seed yourcomposer (text, attachments, model/agent) from chat.draft if present.
draft. Periodically dispatch chat/draftChangedwith the current composer contents. Debounce — eager per-keystroke sync
is not required. Dispatch with no draft to clear it once the message is
sent.
// Debounced (e.g. on idle/blur), not on every keystroke:
store.dispatch(chat, {
type: 'chat/draftChanged',
draft: { text, origin: { kind: MessageKind.User }, attachments, model, agent },
});
// Clear after sending:
store.dispatch(chat, { type: 'chat/draftChanged' });
If you maintain a reducer, the chat/draftChanged case is a plain set/clear
({ ...state, draft: action.draft }) and does not stamp modifiedAt.
model / agent from createSession / createChatThese command params no longer accept model / agent. Convey an initial
selection on the first message instead.
await client.createSession({
channel: sessionUri,
provider: 'copilot',
- model: { id: 'gpt-4o' },
- agent: { uri: agentUri },
workingDirectory,
});
await client.createChat({
channel: sessionUri,
chat: chatUri,
- model: { id: 'gpt-4o' },
- agent: { uri: agentUri },
- initialMessage: { text, origin: { kind: MessageKind.User } },
+ initialMessage: { text, origin: { kind: MessageKind.User }, model, agent },
});
Mirror the canonical reducers in types/channels-session/reducer.ts and
types/channels-chat/reducer.ts:
session/titleChanged → set state.title; no modifiedAt stamp.session/isReadChanged / session/isArchivedChanged → flip the flag onstate.status via your withStatusFlag helper.
session/activityChanged → set state.activity.session/configChanged → merge/replace state.config.values; nomodifiedAt stamp.
session/modelChanged and session/agentChanged cases.modifiedAt (thesession reducer no longer owns a timestamp). Keep your chat reducer's own
modifiedAt stamping and its injectable "now" seam.
chat/draftChanged case: state.draft = action.draft (set or clear).The shared conformance fixtures in types/test-cases/reducers/ already encode
the new shapes (including 223-chat-draftchanged-sets-draft and
224-chat-draftchanged-clears-draft); run them against your reducer.
Run these searches in your codebase. Each pattern is a strong signal that a
migration site still needs attention.
| Pattern | What it indicates |
|---------|-------------------|
| .summary. near a session value (session.summary, state.summary, summary.title, summary.status, summary.workingDirectory) | Reading metadata off the removed SessionState.summary (Pass 1) |
| summary: inside a SessionState literal | Constructing the old nested shape (Pass 1) |
| summary.modifiedAt / summary.createdAt used in arithmetic or -/</> numeric compare | Numeric timestamp assumptions (Pass 3) |
| createdAt: / modifiedAt: set to a number, Date.now() into a summary | Old numeric timestamps (Pass 3) |
| session/modelChanged, session/agentChanged, SessionModelChangedAction, SessionAgentChangedAction | Removed actions (Pass 4) |
| .model / .agent on a session or chat summary/state value | Removed selection fields (Pass 4) |
| summary.model, summary.agent, changes.model, changes.agent | Removed summary fields / root/sessionSummaryChanged deltas (Pass 4) |
| createSession( / CreateSessionParams with model: or agent: | Removed command params (Pass 7) |
| createChat( / CreateChatParams with model: or agent: | Removed command params (Pass 7) |
| chat/draftChanged, ChatDraftChangedAction, ChatState.draft | New draft surface — confirm you sync/initialize it (Pass 6) |
| Message constructed for a turn without model / agent where a picker exists | Selection not carried on the message (Pass 5) |
After the migration, your code should:
SessionState directly (state.title,state.status, state.activity, state.workingDirectory, …) — no
state.summary.
SessionState flat, without a nested summary, and withoutmodel / agent / createdAt / modifiedAt on it.
SessionSummary only as the root-channel catalog entry(listSessions, root/sessionAdded, root/sessionSummaryChanged).
SessionSummary.createdAt / modifiedAt as ISO-8601 stringseverywhere they are parsed, formatted, sorted, or compared.
Message.model / Message.agent; read ahistoric turn's selection from turn.message.
session/modelChanged / session/agentChanged dispatch or handling,and no model / agent on SessionState / SessionSummary /
ChatState / ChatSummary.
model / agent arguments to createSession / createChat.ChatState.draft and (debounced) sync thecomposer back via chat/draftChanged, clearing it on send.
timestamp, and a chat/draftChanged case sets/clears draft.
When all these are true, your consumer is on the flattened-SessionState model.
For the full normative description, consult these documents in the
microsoft/agent-host-protocol repository:
docs/guide/state-model.md — SessionState, SessionSummary, Message,and the session/chat metadata relationship
docs/specification/session-channel.md — session channel state, thechat-aggregation rules for the root-channel summary, client-action validation
docs/specification/chat-channel.md — chat state, message-level model/agentselection, and the draft / chat/draftChanged behavior
docs/specification/root-channel.md — listSessions, root/sessionAdded,root/sessionSummaryChanged, and the ISO-8601 timestamp examples
types/channels-session/state.ts, types/channels-chat/state.ts —SessionMetadata, SessionState, SessionSummary, ChatState,
ChatSummary, Message source-of-truth definitions
types/channels-session/reducer.ts, types/channels-chat/reducer.ts —canonical reducer behavior to mirror
types/test-cases/reducers/ — shared conformance fixtures, including thechat/draftChanged cases
Take microsoft/session-metadata-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.