One agent session. Two VS Code windows. The host owns the state; the windows are just views.
This repo is the runnable companion to the idea that AHP is the layer nobody notices: not tools (that's MCP), not agent-to-agent delegation (that's A2A), but how a single agent session is served and synchronized to several clients at once. The host holds the state. The clients are views. That one sentence is the whole design, and this repo makes it something you can watch happen.
It is a teaching model, not the Microsoft reference implementation. It reproduces the four load-bearing ideas of the Agent Host Protocol in ~300 lines so the mechanics are legible:
- The host is the source of truth. Clients never hold authoritative state.
- Immutable state, pure reducers. State only advances through
(state, action) => nextStatefunctions with no side effects. - Write-ahead reconciliation. Every mutation gets a monotonic sequence number; clients apply the same ordered actions and cannot diverge.
- URI-addressed channels, snapshot + actions. A fresh client gets a snapshot; a reconnecting one replays missed actions from its last
seq.
If you've read the post this comes from — MCP, A2A y AHP: tres protocolos y por qué tu agente debería vivir en un contenedor — this is the "sesión como recurso servidor" claim, compiled and running.
Two clients (each standing in for a VS Code Agent Sessions window) connect to the same host and the same session id. Type a prompt in Window A; the agent's reply streams into both windows at once, token by token, identical. That's not two agents answering — it's one session, one host-resident agent, fanned out as ordered actions.
=== WindowA | session 4f3a... | seq 12 === === WindowB | session 4f3a... | seq 12 ===
you/other> explain ahp you/other> explain ahp
agent> AHP puts the session on the host: state … agent> AHP puts the session on the host: state …
[agent is thinking] [agent is thinking]
Put Window B offline with /offline, continue the conversation in Window A, then enter /online in Window B. It reconnects with its last seq and the host replays only the missed actions from that session's write-ahead log. Keep the client process alive during this test: sinceSeq assumes the client still has the local view produced by the earlier snapshot. A brand-new process correctly starts from a fresh snapshot instead.
This is the output of a real npm run verify:reconnect run. The verifier starts the compiled host on a free port and connects real WebSocket clients. Ports, connection ids, and session UUIDs are normalized; sequence numbers and action counts are untouched.
[1/9] HOST
listening: true
authoritative state: host
[2/9] SESSION + WINDOW A
session: <shared-session-id>
channel: ahp:/sessions/<shared-session-id>
sync: snapshot at seq 1
[3/9] WINDOW B JOINS
sync: snapshot at seq 1
same session: true
[4/9] LIVE SHARING
prompt sent by: WindowA
prompt visible in WindowB: true
streamed answer visible in both windows: true
both windows at seq: 51
[5/9] WINDOW B OFFLINE
retained local view at seq: 51
unrelated session action added: true
[6/9] WINDOW A CONTINUES
user> explain sync
agent> AHP puts the session on the host: state is immutable, reducers are pure, and every client applies the same ordered actions. That's why you're seeing this text appear identically in every window connected to me.
WindowA seq: 124
[7/9] WINDOW B RECONNECTS
subscribe sinceSeq: 51
replayed session actions: 72
caught up to global seq: 124
second snapshot received: false
[8/9] HOST EVIDENCE
[ahp-host] wal seq=1 action=session/created session=<shared-session-id> fanout=0
[ahp-host] session <shared-session-id> created by client <client-id>
[ahp-host] client <client-id> subscribed channel=ahp:/sessions/<shared-session-id> subscribers=1
[ahp-host] client <client-id> snapshotted at seq 1
[ahp-host] client <client-id> subscribed channel=ahp:/sessions/<shared-session-id> subscribers=2
[ahp-host] client <client-id> snapshotted at seq 1
[ahp-host] prompt received client=<client-id> session=<shared-session-id> text="hello"
[ahp-host] wal seq=2 action=turn/user session=<shared-session-id> fanout=2
[ahp-host] wal seq=3 action=turn/agentStarted session=<shared-session-id> fanout=2
[ahp-host] wal seq=51 action=turn/agentDone session=<shared-session-id> fanout=2
[ahp-host] turn complete session=<shared-session-id> seq=2..51 deltas=47
[ahp-host] wal seq=52 action=session/created session=<unrelated-session-id> fanout=0
[ahp-host] prompt received client=<client-id> session=<shared-session-id> text="explain sync"
[ahp-host] wal seq=53 action=turn/user session=<shared-session-id> fanout=1
[ahp-host] wal seq=54 action=turn/agentStarted session=<shared-session-id> fanout=1
[ahp-host] wal seq=124 action=turn/agentDone session=<shared-session-id> fanout=1
[ahp-host] turn complete session=<shared-session-id> seq=53..124 deltas=69
[ahp-host] client <client-id> resynced channel=ahp:/sessions/<shared-session-id> from seq 51 (+72 actions)
[9/9] CONSISTENCY CHECKS
other-session actions filtered: true
shared session byte-identical: true
final WindowA seq: 124
final WindowB seq: 124
POC result: PASS
The host evidence makes the ownership boundary visible. It receives each prompt, reduces it into sequenced WAL actions, and reports fanout=2 while both views are subscribed. After Window B disconnects, the next turn reports fanout=1; B later asks for sinceSeq: 51 and receives 72 matching session actions instead of another snapshot.
The global WAL also contains the unrelated session/created action at seq 52. It advances the final global sequence but is excluded from B's channel replay, which is why the consistency checks can prove both channel isolation and a byte-identical final shared session.
sequenceDiagram
autonumber
actor User
participant A as Window A<br/>thin client
participant H as AHP Host<br/>source of truth
participant W as In-memory WAL
participant G as Host-resident agent
participant B as Window B<br/>thin client
User->>A: Start without a session id
A->>H: createSession(title)
H->>H: rootReducer(session/created)
H->>W: append seq 1 + session/created
H-->>A: sessionCreated(sessionId)
A->>H: subscribe(ahp:/sessions/id)
H-->>A: snapshot(state, seq 1)
H-->>A: syncComplete(snapshot, seq 1)
A->>A: Store local read-only view
B->>H: subscribe(same session URI)
H-->>B: snapshot(state, seq 1)
H-->>B: syncComplete(snapshot, seq 1)
B->>B: Store local read-only view
User->>A: explica como funciona sinceSeq en AHP
A->>H: prompt(sessionId, text)
H->>H: Reduce user turn + agentStarted
H->>W: Append ordered actions
H->>G: runAgentTurn(text)
loop Every streamed agent token
G-->>H: delta
H->>H: rootReducer(turn/agentDelta)
H->>W: append next seq + action
H-->>A: action(seq, channel, delta)
H-->>B: action(seq, channel, delta)
A->>A: rootReducer(local view, action)
B->>B: rootReducer(local view, action)
end
Note over A,B: Both views contain identical state and seq
B-xH: /offline closes WebSocket
Note over B: Keep local view and lastSeq = k
User->>A: Send another prompt while B is offline
A->>H: prompt(sessionId, text)
H->>W: Append user, started, delta, done actions
H-->>A: Stream ordered actions
B->>H: /online reconnects
B->>H: subscribe(channel, sinceSeq: k)
H->>W: Read entries where seq > k AND channel matches
loop Each missed action only
W-->>H: seq + action
H-->>B: action(seq, channel, action)
B->>B: rootReducer(local view, action)
end
H-->>B: syncComplete(replay, currentSeq, replayed)
Note over A,B: Window B catches up without another snapshot
The sequence has four guarantees: the host is authoritative, mutations enter the WAL before fan-out, all online views reduce the same ordered actions, and a reconnecting view replays only actions newer than its sinceSeq on the subscribed session channel.
Microsoft's own self-hosting matrix has three shapes. This repo covers the first two locally and the third via the VM:
| Config | Where the host runs | This repo |
|---|---|---|
| VS Code + local agent host | same machine as the editor | npm run host on your box |
| Agents app + local agent host | same machine, headless | second client on same box |
| Agents app / VS Code + remote agent host (SSH / dev tunnel) | another machine | the Bicep VM in infra/ |
The remote config is the interesting one, and it's where your IP argument lands: the agent loop and the workspace live on the VM; the client is a thin view reaching in over a tunnel. File edits and the agent's reasoning happen server-side. Nothing about how the agent works materializes on the client.
A concrete reason to care about "one session, many clients" is running two different VS Code builds against the same session — say Insiders (preview) and Stable — to compare behavior without forking the session state:
- Point both builds'
chat.agentHost.enabledat the same host ("chat.agentHost.enabled": true, then connect to the remote host over a dev tunnel). - Because the host owns state and both clients are pure-reducer views, the session is identical in both windows. Any difference you observe is the client, not the conversation — which is exactly what you want when A/B-testing a preview build.
- In this demo you fake the two builds with two
ahp-clientprocesses; swap them for real Insiders + Stable connecting to the VM host and the story is the same.
This is the payoff of moving the session off the window: the session stops being "a transient thing tied to a particular editor window" and becomes a resource two editors can share.
cd ahp-two-window-demo/ahp-demo
npm ci
npm run build
# Terminal 1 — the host (owns all state)
npm run host
# Terminal 2 — Window A (creates a session, prints its id)
npm run client -- --name WindowA
# Terminal 3 — Window B (joins the SAME session)
npm run client -- --name WindowB --session <id-printed-by-WindowA>Type in either window. Watch both. Try ahp, sync, or ip as prompts — the host-resident agent has canned answers so the demo runs fully offline.
With both windows connected to the same session:
# In Window B: disconnect the transport but keep its local view.
/offline
# In Window A: send one or more prompts while B is offline.
explain sync
# In Window B: reconnect from its last sequence number.
/online
Window B catches up without receiving another snapshot and reports the result in its status line:
[online | replayed <count> actions, caught up to seq <current> | ready — type a prompt]
The host logs the same reconciliation (resynced channel=ahp:/sessions/<id> from seq <last-seq> (+<count> actions)). Replay is scoped to the subscribed session channel, so activity in other sessions is not applied to this view.
npm run verifyThis reduces one action stream two ways — a fresh client and a late joiner that snapshots mid-stream then replays — and asserts they end byte-identical. That equality is the consistency guarantee AHP gives you.
To exercise the actual WebSocket server, including disconnect, sinceSeq replay, and channel isolation:
npm run verify:reconnectThe "remote agent host" config. The Bicep in infra/ stands up one Ubuntu 24.04 VM, installs Node via cloud-init, and runs the host as a systemd service on port 4611.
az group create -n rg-ahp-demo -l westeurope
az deployment group create \
-g rg-ahp-demo \
-f infra/main.bicep \
-p adminUsername=azureuser \
adminPublicKey="$(cat ~/.ssh/id_rsa.pub)"Outputs give you ahpEndpoint (ws://<ip>:4611) and the SSH command. Point your clients at it:
npm run client -- --name WindowA --url ws://<ip>:4611
npm run client -- --name WindowB --url ws://<ip>:4611 --session <id>Do not ship port 4611 open to the internet. The demo NSG opens it for convenience. In practice, keep the host private and reach it through a VS Code dev tunnel or an SSH forward (
ssh -L 4611:localhost:4611 azureuser@<ip>). That's not just hygiene — it's the whole IP argument: if the host is private and the client only holds a view, the client never crosses into your infrastructure.
The reason "the host owns state, the client is a view" matters beyond neat sync: the agent loop, the prompt, the tool wiring — the parts that are actually your product — never leave the host. In this demo that's src/agent/agent.ts running inside the host process. Move that host into a Foundry-style VM-isolated sandbox with a private registry and a dedicated identity, and the editor renders turns while the "how" stays in your tenant.
That's the hardened version, and it's a separate repo: foundry-hosted-agent-ip-poc (signed container in a private ACR, VM-isolated sandbox, dedicated Entra identity). This repo is the protocol mechanics; that one is the governance envelope. AHP is the honest hypothesis in between: if the session is a server resource, then serving it from Azure and treating VS Code as a thin AHP client is the natural place for confidentiality to live.
Honesty, same as the post: AHP is designed to host coding-agent harnesses, not to make a Foundry endpoint act as an AHP server. Bridging the two needs a shim (or Microsoft pointing the agent host at a Foundry harness). It's not a supported path today — it's a direction the architecture makes plausible.
ahp-demo/
├─ src/
│ ├─ host/
│ │ ├─ ahp-host.ts # the server: owns state, sequences actions, fans out
│ │ ├─ protocol.ts # shared types (state, actions, wire messages)
│ │ └─ reducers.ts # pure (state, action) => state — shared with clients
│ └─ agent/
│ └─ agent.ts # host-resident agent adapter (swap for a real model)
├─ clients/
│ └─ ahp-client.ts # thin client == one VS Code window (a VIEW)
├─ infra/
│ ├─ main.bicep # one VM = the remote agent host
│ └─ cloud-init.yaml # installs Node, runs the host as a systemd service
└─ README.md
| This demo | Real AHP |
|---|---|
| JSON over WebSocket | JSON-RPC (WebSocket in practice) |
rootReducer / reduceSession |
reducers copied verbatim from VS Code (MIT), pure functions |
seq + in-memory WAL |
write-ahead reconciliation, ordered actions, resync-or-snapshot |
subscribe to ahp:/sessions/<id> |
URI-addressed channels (sessions, chats, terminals, changesets) |
two ahp-client processes |
two VS Code Agent Sessions windows / AHPX CLI / Agents app |
agent.ts in-process |
first-party adapters (Copilot, Claude Agent SDK, Codex) in the host |
| the Bicep VM | remote agent host over SSH / dev tunnel |
Spec and SDKs: https://github.com/microsoft/agent-host-protocol · Concepts: https://code.visualstudio.com/docs/agents/concepts/agent-host