@xenosystem/agent-host-client
v0.1.0
Published
The dependency-free client for the ONE per-user XENO Agent host: find the lease, refuse a stale one, handshake, and drive conversations, provider lanes and the runtime event stream.
Readme
@xenosystem/agent-host-client
Reach the one per-user XENO Agent host from any Node process, with no dependencies.
Apps do not each embed an agent (XENO AGENT PANEL - SPEC.md §3 D1). One host runs per user and
every other process attaches to it as a client — which is what buys three lanes (cloud / local /
acp), one session store, one permission engine, one audit log, and the possibility of an agent that
can see several apps at once.
import { attachToAgentHost, listProviders, openConversation } from '@xenosystem/agent-host-client'
const { status, connection } = await attachToAgentHost({
clientId: 'xeno-apps',
clientVersion: '0.1.0',
// 🔴 Subscribe HERE, not on the returned connection: the host starts streaming on its first
// authenticated request and the handshake IS that request.
onEvent: (envelope) => forwardToRenderer(envelope),
})
if (status.state === 'attached') {
const catalog = await listProviders(connection!) // → the panel's `catalog` port
const conversation = await openConversation(connection!) // → its lanes, and its id
}Four things that are easy to get wrong
- A lease is a claim, not a fact.
~/.xeno/agent-host-v1/agent-host.locksurvives a hard kill, pids get recycled, and ports get reused.assessLeaseFreshnessrefuses a dead one; the handshake is the only step that is evidence. state: 'no-host'is the NORMAL case. Most launches happen with no agent host at all. A consumer must work completely without one.- Refusals arrive by two different routes. The transport answers
ok:falseand destroys the socket;conversation.*andprovider.lanes.*answersuccess:falsein the payload. Both are turned into throws here so a caller cannot mistake the second for success. - There is no lane state before a conversation exists.
provider.lanes.listtakes a conversationId —conversation.openreturns the lanes itself.
Running a REAL host to test against
🔴 Do this before trusting any adapter written against this client. A producer written from a panel's side and a host's actual output are two different things — measured 2026-09-06, the panel package's reference adapter understood zero of a real turn's 13 events, and every unit test in that package was green.
The host composes from its own building blocks; nothing here needs credentials, because the FIXTURE is the agent and everything else is shipped code:
// packages/host/dist and packages/test-kit/dist of xeno-agent-interface
const { startLocalAgentHostAuthority } = await import('.../packages/host/dist/localHostAuthority.js')
const { AgentHostCoordinator } = await import('.../packages/host/dist/index.js')
const { RuntimeEventStore } = await import('.../packages/host/dist/runtime/RuntimeEventStore.js')
const { RuntimeEventStoreRepositoryAdapter } = await import('.../packages/host/dist/runtime/RuntimeEventStoreRepositoryAdapter.js')
const { AcpAgentRegistryService, createFixtureAcpAgentConfig } =
await import('.../packages/host/dist/acp/AcpAgentRegistryService.js')
const { AcpAgentTurnExecutionAdapter, createAcpRuntimeProviderDescriptors } =
await import('.../packages/host/dist/acp/AcpAgentTurnExecutionAdapter.js')
const registry = new AcpAgentRegistryService({ rootDir: work, registryPath: join(work, 'acp-agents.json') })
registry.saveAgent({ ...createFixtureAcpAgentConfig(work, 'normal', 'fixture-turn'),
env: { XENO_ACP_FIXTURE_MODE: 'normal' } })
const runtimeStore = new RuntimeEventStore(join(work, 'runtime-events'))
const providers = await createAcpRuntimeProviderDescriptors(registry)
await startLocalAgentHostAuthority({
rootDirectory: root, // pass this same root to attachToAgentHost
instanceId: `probe-${process.pid}`,
createHostTransport: async () => new AgentHostCoordinator({
hostVersion: '0.0.0-probe', hostInstanceId: `probe-${process.pid}`,
processBoundary: 'per-user-service', storageSchemaVersion: 2, eventSchemaVersion: 1,
providers,
runtimeEventRepository: new RuntimeEventStoreRepositoryAdapter(runtimeStore),
turnExecutionAdapters: [new AcpAgentTurnExecutionAdapter({ registryService: registry, runtimeStore })],
}),
})Then attach with root, call listProviders, and startTurn. Record onEvent to capture the
frames.
⚠️ Two things that will look like client defects and are not:
conversation.openis REFUSED by a host built this way — "Host-owned provider lane services are unavailable" — because it has noProviderLaneService.turn.startworks anyway: the two are less coupled than they look.- The two refusals arrive by DIFFERENT routes.
conversation.openfails as a transportok:falseframe;turn.startfails in band withsuccess:false. This client turns both into throws, which is why a caller cannot mistake the second for success.
Status
⚠️ Not published, and one consumer cannot use it yet. xeno-canvas carries its own copy of this
protocol in src/main/agentHost.ts — this package was derived from it — because Canvas declares
zero file: dependencies and that is the only reason its CI can install. The exit is the same
unexecuted clause that strands @xeno/account: published for all consumers. The duplication is
recorded rather than hidden, and AGENT_HOST_PROTOCOL_VERSION is asserted on every frame so a
drift between the two copies is loud instead of subtle.
