Appearance
Build your own host
createHost is the protocol and nothing else: no agent logic, and nothing that touches the machine. It is the same function the daemon in packages/server/src/main.ts calls.
This is the host half. Writing the backend that answers is AGENT.md.
Four things go into a host, and only the first two are required:
path | the directory whose sessions this host serves | required |
agents | the backends it serves. claude() is one; anything satisfying Agent is another | required |
| ports | resources, terminals, changes, directories, automations, worktrees | opt-in |
tools | tools this host contributes to every session it runs | opt-in |
diagnostics | its version, its log files and how it stops, for the window's own requests | opt-in |
onEvent | one line per notable event, for a log | opt-in |
The smallest host
ts
import { createHost, listen } from '@ahpd/sdk';
import { claude } from '@ahpd/agent-claude';
const host = createHost({
path: process.cwd(),
agents: [claude({ paths: [process.cwd()] })],
});
const listener = await listen({ port: 9187 }, (peer) => host.accept(peer));
console.log(`on ws://${listener.host}:${listener.port} (${listener.runtime})`);That already serves version negotiation, snapshots, subscriptions, sequence numbers, transcript paging, completions, queued messages, shared drafts, read and archived flags, several chats per session, and the whole of a turn.
accept(peer) is the seam: it takes something that can send, notify and close, and returns a handler. listen is one implementation of a peer over a WebSocket, on Node, Bun or Deno; a test is another, which is why the whole suite runs without a network.
A complete host
ts
import { createHost, listen, fileResources, shellTerminals, gitChanges, gitBranches, memoryAutomations } from '@ahpd/sdk';
import { claude } from '@ahpd/agent-claude';
const path = process.cwd();
const host = createHost({
path,
agents: [claude({ paths: [path] })],
resources: fileResources(),
terminals: shellTerminals(),
changes: gitChanges(),
directories: gitBranches(),
automations: memoryAutomations(),
onEvent: (line) => process.stdout.write(`${line}\n`),
});
await listen({ port: 9187 }, (peer) => host.accept(peer));The ports
Each is optional and independent. Leave one out and the commands behind it answer -32601, which is what a client degrades on; a host that half-answered would fail part-way through instead.
| port | what it is | left out | the one that ships |
|---|---|---|---|
resources | files a client may read, what @ completes into, and the write half | no resource* command is served | fileResources() |
terminals | how a shell is opened | no terminal can be created | shellTerminals() |
changes | what the working tree has that HEAD does not | no session advertises a changeset, and the changes screen is honestly empty rather than emptily wrong | gitChanges() |
directories | facts about a served directory - the branch it is on | sessions carry their project and nothing more | gitBranches() |
github | what GitHub knows about that branch - its pull request and the state of it | no row carries _meta.github, and no backend advertises a GitHub resource | githubPullRequests() |
users | who may sign in, and what a role may do | no sign-in resource is advertised, every gate the host has stays inert, and no _meta['ahpd.owner'] on a row and no _meta['ahpd.sender'] on a turn is sent | fileUsers({ path }) |
automations | agents on a trigger | no ahp-automations:// channel is advertised | memoryAutomations(), without a clock; the clock store is the daemon's |
resources
ts
interface ResourceStore {
list(uri): Promise<Entry[]>;
read(uri, wanted?): Promise<Read>;
resolve(uri, followSymlinks?): Promise<Metadata>;
complete(typed, base, limit?): Promise<string[]>;
// The write half, all optional. A store without them is read-only, and a
// write is answered `-32601` rather than refused about a path.
write?(uri, content): Promise<void>;
remove?(uri, recursive?): Promise<void>;
mkdir?(uri): Promise<void>;
move?(source, destination, failIfExists?): Promise<void>;
copy?(source, destination, failIfExists?): Promise<void>;
// Optional. Without it `createResourceWatch` answers `-32601`, which a
// client degrades on rather than fails on.
watch?(uri, options, onChange): Promise<Watcher>;
}The whole filesystem, as the reference host serves it. The connection token decides who may read; the served directories are where the catalogue looks, not where a client is allowed to.
terminals
create(options) returns a Terminal. packages/sdk/src/terminals.ts is a subprocess over pipes, which is why it reports isPty: false - said rather than left to be discovered, because anything drawing itself with cursor movement will not look right.
changes
ts
interface ChangesetSource {
scopes(dir, session): ChangesetScope[];
state(dir, session, scope): Promise<ChangesetState | undefined>;
summary(dir): ChangesSummary | undefined;
read?(uri): Promise<{ data: string; encoding: string } | undefined>;
refresh?(dir): Promise<boolean>;
watch?(dir, onChange): ChangesetWatch | undefined;
review?(dir, session, scope, files, reviewed): boolean;
observe?(dir, session, turnId, path, phase): void;
operations?(dir, session, scope): ChangesetOperation[];
invoke?(request): Promise<ChangesetOperationResult>;
}read is the part worth noticing: a changeset needs both sides of an edit, and what a file used to be is not a file on disk. gitChanges() serves the before-side out of git show HEAD: behind a URI it resolves itself.
watch returns the call that stops it, and that call may carry ready, a promise that resolves once the source's watchers are armed. A change made before then reaches no watcher, so the host reads the directory again when ready resolves. gitChanges() resolves it after its last watcher opens, or when it gives up.
directories
meta(dir) returns whatever this host can say about a directory, and gitBranches() returns { git: { branchName, ... } } - which becomes _meta.git on every session row in that directory, under the reference host's field names. Cached per directory and re-read when a turn ends, so a host with ninety-eight sessions in one repository asks git once.
github
forBranch(repo, branch, token, cwd) answers the pull requests whose head is that branch, newest first; create(repo, wanted, token, cwd) opens one from a branch already pushed; and resource is the protected resource a client lends a token under - the reference host's https://api.github.com/repos, advertised on every backend. gitChanges() is handed the port on each operations and invoke call, as context.github, which is what makes it offer prepare-pull-request and create-pr. The host puts the answer beside the git facts as _meta.github: owner, repo, pullRequestUrls, pullRequestBranchName, and pullRequestState (open, closed, merged) with the pullRequestStateUrl it is the state of. Asked at startup, when a token arrives, and when a turn ends. githubPullRequests() asks the REST API with a lent token and gh pr list without one; a machine with neither answers no pull request rather than failing, and an answer that fails keeps what was held.
automations
memoryAutomations() holds definitions and runs them when asked. It has no clock, and it says so by leaving nextRunAt off. The clock store is the daemon's, in @ahpd/server, and that package exports no library: a five-field cron in a named time zone, one Markdown file per definition under <configDir>/automations, and one catch-up run for what was missed while the host was down. A program that wants a clock writes its own AutomationStore over memoryAutomations(), with parseCron and nextOccurrence from this package. A run reads running while any session it started is executing or waiting on a person, and the host says the execution ended through settle: a store that implements it records the terminal completed, failed or cancelled and announces it, and one that keeps its runs immutable leaves the method out.
The tools
tools is not a port - nothing behind it is a command a client calls - but it is handed in the same way and for the same reason: what a host knows is the host's business.
They are the protocol's serverTools: tools that are neither a backend's nor a client's. Each is a ToolDefinition the model is offered and a run that is called when the model calls it, with the arguments it passed and a view of the session it called from. They are reported on SessionState.serverTools, given to every backend that can take tools, and replaced whole with host.setTools()
- which dispatches
session/serverToolsChangedto every running session.
hostTools() is the set that ships: the reference host's nine session tools (list_sessions, get_current_session, get_session_context, send_message, create_session, create_chat, rename_chat, delete_session, set_workspace), with its names, input schemas and answers, so a skill written against VS Code's host calls them here unchanged; and two of this host's own, ahp_resource and ahp_terminals, both read-only, answering what an agent inside one session cannot see for itself - a client's published resource and the terminals a person is watching; and the reference host's three artifact tools (add_artifact_or_reference, remove_artifact_or_reference, list_artifacts_and_references), which write the session's _meta['agentHost/sessionArtifacts'] through the session store. sessionTools() and artifactTools() are each set on their own. A tool may carry an instruction, which a backend that can add to its system prompt adds while the tool is offered; the artifact tools carry the reference host's.
ts
createHost({
path,
agents,
tools: [
...hostTools(),
{
definition: {
name: 'deploy_status',
description: 'What is currently deployed',
inputSchema: { type: 'object', properties: { environment: { type: 'string' } } },
},
run: async (input) => ask(String(input.environment ?? 'production')),
},
],
});What the window asks a host about itself
diagnostics is not a port either. The reference window has requests of its own for the host it runs - shutdown, getNetworkDiagnosticsInfo, diagnosticsFetch, getManagedSettingsDiagnostics, vscode/getAgentHostSessionStateFile, vscode/collectAgentHostDebugLogs and vscode/readAgentHostDebugLogsChunk - and this is what the host answers them from: version goes into serverInfo and the network report, logs() names the files a "collect logs" packs up under agenthost/, and shutdown() is run once the request has been answered. Leave it out and the host still answers the network and log requests with what it has; shutdown alone answers -32601. The session's own file comes from the agent's stateFile, and the endpoints to probe from its endpoints().
The agents
agents is the one option with no default: a host serves at least one backend, each with a provider no other has. The first is what a client gets when it names none.
ts
import { createHost } from '@ahpd/sdk';
import { claude } from '@ahpd/agent-claude';
import { notes } from './my-agent.js';
createHost({ path, agents: [claude({ paths: [path] }), notes({ path })] });Nothing in the host knows what any of them are. claude() is one that ships with this package; anything satisfying Agent is another, and the two are registered identically.
Writing one is its own document: AGENT.md has the Agent and Session contracts, config keys and setConfig, and the rules that produce a wrong screen rather than an error. examples/echo and examples/notes are two complete ones.
What else is exported
ts
// @ahpd/sdk - the protocol, the ports, and everything that is not a backend
import {
createHost, ROOT, // the host, and the root channel URI
listen, // a socket, on Node, Bun or Deno
createPeer, receive, RpcError, // JSON-RPC, holding no socket
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INTERNAL_ERROR,
fileResources, shellTerminals, // the resources and terminals ports
gitChanges, gitBranches, gitWorktrees, // the git-backed ports
githubPullRequests, // the pull request beside a branch
memoryAutomations, // automations, without a clock
hostTools, // the tools this package contributes
uriFor, idFor, idOf, Status, // how a session is named, and its status bits
tail, older, PAGE, // paging a long list of turns
} from '@ahpd/sdk';
// @ahpd/agent-claude - one backend, and nothing the host needs to know about
import {
claude, // the `Agent` to register
createSession, // one live session of it
catalogue, // Claude's own sessions, as rows a host can list
turnsOf, // a past session read out of its transcript
probe, // one CLI at startup, to learn what it offers
} from '@ahpd/agent-claude';Every type is exported too, from packages/sdk/src/types/.