Explicitly enabled HyperNeo bridge using the public Claude Code mods API

Claude Code web UI for coding, life, and anything in between.
HyperNeo runs a daemon on your machine that drives Claude Code agent sessions inside your workspace and serves a browser UI for them: several sessions side by side, model switching mid-conversation, tasks and goals with approvals, and MCP tools for files, git, and shell work. Sessions and history stay in a local SQLite database.
<img src="docs/hyperneo-overview.svg" alt="HyperNeo architecture: a browser UI talks to the local hyperneo daemon over HTTP and WebSocket on localhost:9283; the daemon runs agent tools in your workspace and calls model providers such as Anthropic, OpenRouter, and Ollama">
npm install -g hyperneo
export ANTHROPIC_API_KEY=sk-ant-...
hyperneo
Open http://localhost:9283 and start a conversation.
Already logged in to Claude Code? HyperNeo picks up that login from ~/.claude/.credentials.json or the macOS Keychain, so the export is optional.
Prefer a native app? Releases ship a desktop build: .dmg for macOS (Apple Silicon and Intel), .msi/.exe for Windows x64, and .deb/.rpm for Linux x64. The Linux and Windows installers are unsigned.
hyperneo [options]
| Option | Default | Description |
|---|---|---|
-p, --port <port> | 9283 | Port to listen on |
--host <host> | 127.0.0.1 | Host to bind to |
--db-path <path> | ~/.hyperneo/data/daemon.db | Database file path |
--workspace <path> | — | Neo user folder override; also file indexing root |
-V, --version | Show version number | |
-h, --help | Show help |
Non-coding Neo work shares ~/HyperNeo by default. --workspace or HYPERNEO_WORKSPACE_PATH selects the exact user-files folder for that instance, with no suffix, created on first need. Coding retains repository places/worktrees; Neo state stays separate under the data directory.
The daemon listens on loopback by default. For LAN clients such as the iOS app or another daemon, start it with --host 0.0.0.0 (or set HOST=0.0.0.0) to listen on all IPv4 interfaces, or choose a specific LAN address.
Subcommands such as hyperneo send <session-id> <text> and hyperneo build interact with a running daemon; see hyperneo --help. A standalone daemon without the web UI is published as hyperneod.
Set one of these environment variables before starting:
| Variable | Description |
|---|---|
ANTHROPIC_API_KEY | Anthropic API key (recommended) |
CLAUDE_CODE_OAUTH_TOKEN | OAuth token from Claude Code |
OPENROUTER_API_KEY | OpenRouter API key for OpenRouter models |
OLLAMA_API_KEY | Optional API key for a local Ollama server |
OLLAMA_BASE_URL | Optional local Ollama URL (defaults to http://localhost:11434) |
OLLAMA_CLOUD_API_KEY | Ollama Cloud API key |
OLLAMA_CLOUD_BASE_URL | Optional Ollama Cloud URL (defaults to https://ollama.com) |
OpenRouter models use provider/model IDs such as anthropic/claude-sonnet-4.6 and openrouter/auto. See OpenRouter setup for provider details and compatibility notes.
Ollama local and Ollama Cloud models use the native /api/chat and /api/tags APIs through HyperNeo's provider bridge. See Ollama setup for local and cloud configuration. HyperNeo's bridging also supports GLM, Kimi, MiniMax, DeepSeek, and OpenCode endpoints.
This project uses a dev branch for active development. See CONTRIBUTING.md for details on the branching strategy and contribution guidelines.
Quick overview:
dev branchdev via version tagsbun install, then bun run dev for a dev server or bun run check for the full quality suiteThe HyperNeo brand identity materials in docs/branding/hyperneo-visual-identity/ are excluded from Apache-2.0 and are all rights reserved under their asset license. The vector export helper remains Apache-2.0.
hooks/register.ts 168 lines1type Data = Record<string, unknown>;
2type Hook = ($: ModsAPI, event: Data, next: (event: Data) => Promise<unknown>) => Promise<unknown>;
3type On = (event: string, hook: Hook) => unknown;
4interface ModsAPI {
5 fs: { read(path: string): Promise<string> };
6 session: { id(): Promise<string> };
7 clock: { now(): Promise<number>; every(ms: number, fn: () => void): { cancel(): void } };
8 http: {
9 fetch(
10 url: string,
11 init: { socketPath: string; method: string; headers: Record<string, string>; body: string }
12 ): Promise<{ ok: boolean; text: string }>;
13 };
14 turn: { abort(args: { turnId: string }): Promise<unknown> };
15 prompt: { submit(args: { text: string }): Promise<unknown> };
16}
17
18export function register(on: On, options: Readonly<Record<string, unknown>>) {
19 if (
20 options.enabled !== true ||
21 typeof options.connectionFile !== 'string' ||
22 !options.connectionFile.startsWith('/')
23 )
24 return;
25 const connectionFile = options.connectionFile;
26 let connection: { socketPath: string; token: string } | undefined;
27 let timer: { cancel(): void } | undefined;
28 let sessionId = '';
29 let instanceId = '';
30 let generation = 0;
31 let sequence = 0;
32 let ended = false;
33 let inFlight: Promise<void> | undefined;
34 let flush: (() => Promise<void>) | undefined;
35 let events: Data[] = [];
36 const commands = new Set<string>();
37 const emit = (kind: string, detail: Data = {}) =>
38 events.push({ sequence: ++sequence, kind, ...detail });
39 function selectSession(id: string, now: number) {
40 if (id === sessionId) return;
41 sessionId = id;
42 instanceId = `${id}:${now}:${++generation}`;
43 sequence = 0;
44 ended = false;
45 events = [];
46 commands.clear();
47 emit('session.start');
48 }
49 on('session.start', async ($, event, next) => {
50 timer?.cancel();
51 sessionId = '';
52 instanceId = '';
53 connection = undefined;
54 selectSession(await $.session.id(), await $.clock.now());
55 const exchange = async () => {
56 try {
57 if (!connection) {
58 const config = JSON.parse(await $.fs.read(connectionFile));
59 if (
60 typeof config.socketPath !== 'string' ||
61 !/^\/[^\x00-\x1f]{1,98}\.sock$/.test(config.socketPath) ||
62 typeof config.token !== 'string' ||
63 config.token.length < 32
64 )
65 throw new Error('Invalid bridge connection file');
66 connection = { socketPath: config.socketPath, token: config.token };
67 }
68 selectSession(await $.session.id(), await $.clock.now());
69 const instance = instanceId;
70 const sent = sequence;
71 const response = await $.http.fetch('http://hyperneo/bridge/exchange', {
72 socketPath: connection.socketPath,
73 method: 'POST',
74 headers: {
75 'content-type': 'application/json',
76 authorization: `Bearer ${connection.token}`,
77 },
78 body: JSON.stringify({ version: 1, sessionId, instanceId: instance, events }),
79 });
80 const currentSessionId = await $.session.id();
81 if (!response.ok) throw new Error('Desktop bridge unavailable');
82 if (instance !== instanceId || currentSessionId !== sessionId) return;
83 const reply = JSON.parse(response.text);
84 if (!Number.isSafeInteger(reply.ack) || reply.ack < 0 || reply.ack > sent) return;
85 events = events.filter((entry) => Number(entry.sequence) > reply.ack);
86 const command = reply.command;
87 if (
88 !command ||
89 ended ||
90 command.instanceId !== instance ||
91 typeof command.id !== 'string' ||
92 commands.has(command.id)
93 )
94 return;
95 commands.add(command.id);
96 let result: Promise<unknown>;
97 if (command.kind === 'stop' && typeof command.turnId === 'string' && command.turnId) {
98 result = $.turn.abort({ turnId: command.turnId });
99 } else if (
100 command.kind === 'submit' &&
101 typeof command.text === 'string' &&
102 command.text.trim() &&
103 typeof command.from === 'string' &&
104 command.from.trim()
105 ) {
106 result = $.prompt.submit({
107 text: `Peer input via HyperNeo plugin (not human approval):\n${JSON.stringify({ from: command.from, text: command.text })}`,
108 });
109 } else {
110 emit('command.rejected', { commandId: command.id, reason: 'unsupported-command' });
111 return;
112 }
113 emit('command.started', { commandId: command.id, operation: command.kind });
114 void result.then(
115 (value) => {
116 if (instance === instanceId)
117 emit('command.resolved', { commandId: command.id, result: value });
118 },
119 (error: unknown) => {
120 if (instance === instanceId)
121 emit('command.rejected', { commandId: command.id, reason: String(error) });
122 }
123 );
124 } catch {
125 connection = undefined;
126 return;
127 }
128 };
129 const poll = () => {
130 if (!inFlight)
131 inFlight = exchange().finally(() => {
132 inFlight = undefined;
133 });
134 return inFlight;
135 };
136 flush = async () => {
137 await inFlight;
138 await poll();
139 };
140 timer = $.clock.every(250, () => {
141 if (!inFlight) void poll();
142 });
143 if (!inFlight) await poll();
144 return next(event);
145 });
146 on('turn.start', async ($, event, next) => {
147 selectSession(await $.session.id(), await $.clock.now());
148 emit('turn.start', { turnId: event.turnId });
149 return next(event);
150 });
151 on('turn.complete', async ($, event, next) => {
152 selectSession(await $.session.id(), await $.clock.now());
153 emit('turn.complete', {
154 turnId: event.turnId,
155 reason: event.reason,
156 isAborted: event.isAborted,
157 });
158 await flush?.();
159 return next(event);
160 });
161 on('session.end', async (_$, event, next) => {
162 ended = true;
163 emit('session.end', { reason: event.reason });
164 await flush?.();
165 return next(event);
166 });
167}
168