Skip to content

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:

paththe directory whose sessions this host servesrequired
agentsthe backends it serves. claude() is one; anything satisfying Agent is anotherrequired
portsresources, terminals, changes, directories, automations, worktreesopt-in
toolstools this host contributes to every session it runsopt-in
diagnosticsits version, its log files and how it stops, for the window's own requestsopt-in
onEventone line per notable event, for a logopt-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.

portwhat it isleft outthe one that ships
resourcesfiles a client may read, what @ completes into, and the write halfno resource* command is servedfileResources()
terminalshow a shell is openedno terminal can be createdshellTerminals()
changeswhat the working tree has that HEAD does notno session advertises a changeset, and the changes screen is honestly empty rather than emptily wronggitChanges()
directoriesfacts about a served directory - the branch it is onsessions carry their project and nothing moregitBranches()
githubwhat GitHub knows about that branch - its pull request and the state of itno row carries _meta.github, and no backend advertises a GitHub resourcegithubPullRequests()
userswho may sign in, and what a role may dono 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 sentfileUsers({ path })
automationsagents on a triggerno ahp-automations:// channel is advertisedmemoryAutomations(), 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/serverToolsChanged to 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/.