@shaik-noor/web-terminal-helper
v0.1.2
Published
Local helper daemon for web-terminal
Readme
@shaik-noor/web-terminal-helper
Local helper daemon for Web Terminal. Runs locally on your laptop/machine to connect the Web Terminal interface with your local Claude Code CLI and tools.
Requires Node.js 22.13+ and Claude Code installed and logged in (run claude once).
Terminal chat
Chat with your local Claude Code CLI straight from the terminal, no web app or setup needed:
npx @shaik-noor/web-terminal-helper chat # works in ~/web-terminal-workspace (created automatically)
npx @shaik-noor/web-terminal-helper chat . # or the current folder
npx @shaik-noor/web-terminal-helper chat C:\my\repo # or any project folder/new starts a fresh session, /exit quits, Ctrl+C stops a running reply. Claude asks before running tools; answer y or n.
If a helper is already running (via start), chat connects to it instead of starting its own.
Web app helper
You can run the helper without installing anything permanently:
npx @shaik-noor/web-terminal-helper start --origin https://your-web-terminal-domain.comLocal Development
For local web app testing on http://localhost:5173:
npx @shaik-noor/web-terminal-helper start --devOptions
-p, --port <number>: Port to bind (default:7777)-o, --origin <url>: Configured Web App origin (e.g.https://my-vps.com)--dev: Development mode (allows originhttp://localhost:5173)
Commands
start(default): Start the helper WebSocket server and automatically launch the pairing URL in your browser.chat [dir]: Interactive terminal chat (see above).pair: Print and open the pairing URL in your browser.reset-key: Rotate the pairing key and invalidate previous sessions.
Integrating with your web app
A web page cannot start programs on a user's machine. The user starts the helper, it listens on
ws://127.0.0.1:7777 (local only), and your website connects to it from the browser.
User's laptop Browser tab (https://your-app.com)
┌──────────────────────────────┐ WebSocket ┌──────────────────────────────┐
│ web-terminal-helper │◄─────────────►│ your web app │
│ ws://127.0.0.1:7777 │ hello + key │ stores key in localStorage │
│ └─ Claude Code CLI │ │ │
└──────────────────────────────┘ └──────────────────────────────┘1. The user starts the helper once
Show this command on your app's onboarding page, with your exact origin:
npx @shaik-noor/web-terminal-helper start --origin https://your-app.comThe helper:
- saves the origin, port and a random pairing key locally, so later runs only need
npx @shaik-noor/web-terminal-helper start. - opens the user's browser at
https://your-app.com/#key=<pairingKey>&port=7777.
Users who run it often can install it globally: npm i -g @shaik-noor/web-terminal-helper, then web-terminal-helper start.
2. Your app saves the pairing key
Read the key from the URL fragment when the page loads, store it, and remove it from the address bar. Browsers never send the fragment to your server, so the key stays out of your server logs.
const params = new URLSearchParams(location.hash.slice(1));
if (params.get('key')) {
localStorage.setItem('helperKey', params.get('key'));
localStorage.setItem('helperPort', params.get('port') ?? '7777');
history.replaceState(null, '', location.pathname + location.search);
}3. Your app connects and sends hello within 5 seconds
import { DEFAULT_PORT } from '@shaik-noor/web-terminal-helper/protocol';
const port = localStorage.getItem('helperPort') ?? DEFAULT_PORT;
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'hello', key: localStorage.getItem('helperKey') }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'hello.ok':
// msg.claude.available === false means Claude Code is not installed or logged in
ws.send(JSON.stringify({ reqId: '1', type: 'chats.list' }));
break;
case 'auth.error':
// wrong or rotated key: ask the user to run `web-terminal-helper pair`
break;
}
};
ws.onerror = () => {
// helper is not running: show the `start` command from step 1
};On every later visit, connect with the saved key. If the connection fails, show the user the start command.
4. Talk to Claude
Every request can include an optional reqId. The reply echoes it, so you can match replies to requests.
| Request | Fields | Reply |
| --- | --- | --- |
| chats.list | | chats.list.ok { chats } |
| chat.create | cwd (existing folder), title? | chat.create.ok { chat } |
| chat.open | chatId | chat.open.ok { chat, messages, pendingPermissions, running, runId?, partialText? } |
| chat.rename | chatId, title | chat.rename.ok { chat } |
| chat.delete | chatId | chat.delete.ok { chatId } |
| message.send | chatId, text | message.send.ok { chatId } |
| run.stop | chatId | run.stop.ok { chatId } |
| permission.respond | requestId, allow, message? | permission.respond.ok { requestId } |
| folders.recent | | folders.recent.ok { folders } |
The helper also sends these events to every connected client:
| Event | Meaning |
| --- | --- |
| run.started { chatId, runId } | Claude started working on a message |
| stream.text { chatId, runId, delta } | A piece of the reply text as it streams in |
| message.added { chatId, message } | A finished message (user, assistant, tool_use, tool_result, system, error) |
| permission.request { chatId, requestId, toolName, input } | Claude wants to run a tool. Answer with permission.respond |
| permission.resolved { chatId, requestId, allow } | A permission request was answered |
| run.finished { chatId, runId, status, costUsd?, durationMs?, error? } | status is success, error, stopped or interrupted |
| chat.created / chat.updated / chat.deleted | The chat list changed |
| error { reqId?, code, message } | A request failed, e.g. INVALID_CWD, CHAT_NOT_FOUND, INVALID_FRAME |
All schemas, TypeScript types and constants can be imported from @shaik-noor/web-terminal-helper/protocol
(ClientMessageSchema, ServerMessageSchema, ClientMessage, ServerMessage, DEFAULT_PORT, ...).
The main entry also exports HelperServer and runChat.
Things to know
- The origin must match exactly. The browser's
Originheader must equal the configured origin: same scheme, host and port, and no trailing slash.https://your-app.comandhttps://www.your-app.comare different origins. - One origin per machine. The helper stores a single origin. Starting it with a different
--originreplaces the old one. - Only local connections are accepted. The helper binds to
127.0.0.1and only accepts theHostvalues127.0.0.1:<port>andlocalhost:<port>. - Browser support. Chrome and Firefox let
https://pages connect tows://127.0.0.1. Newer Chrome versions may show a "local network access" permission prompt the first time. Test Safari separately. - Security. Anyone with the pairing key and an allowed origin can run Claude Code on the user's machine.
Keep the key out of logs and analytics.
reset-keyrotates it and invalidates old keys.
Testing the helper
You can test the WebSocket protocol without building a web app.
Get a pairing key
npx @shaik-noor/web-terminal-helper start --devThe startup output prints a pairing URL such as http://localhost:5173/#key=AbC123...&port=7777.
The text between key= and &port is your key. Run npx @shaik-noor/web-terminal-helper pair to print it again.
--dev allows the origin http://localhost:5173. If you started with --origin https://your-app.com,
use that origin in the Origin header below.
With Postman
Use the Postman desktop app. The web version can't reach 127.0.0.1 without the Postman Agent.
Click New → WebSocket.
URL:
ws://127.0.0.1:7777In the Headers tab, add
Origin=http://localhost:5173. Without it the connection is rejected with403 Forbidden.The helper closes the connection if
hellodoesn't arrive within 5 seconds. Paste this into the message box before clicking Connect, then click Connect and Send right away:{ "type": "hello", "key": "PASTE_YOUR_KEY" }You should get back
{ "type": "hello.ok", "helperVersion": "...", "claude": { "available": true, ... } }.Send requests one at a time:
{ "reqId": "1", "type": "chats.list" }{ "reqId": "2", "type": "chat.create", "cwd": "C:\\path\\to\\project", "title": "Postman test" }Double the backslashes in Windows paths, and use a folder that exists. Copy
chat.idfromchat.create.ok:{ "reqId": "3", "type": "message.send", "chatId": "PASTE_CHAT_ID", "text": "list the files in this folder" }The reply streams back as
run.started, severalstream.textevents,message.added, thenrun.finished.If you get a
permission.request, answer it with itsrequestId:{ "reqId": "4", "type": "permission.respond", "requestId": "PASTE_REQUEST_ID", "allow": true }To cancel a running reply:
{ "reqId": "5", "type": "run.stop", "chatId": "PASTE_CHAT_ID" }
With wscat
From a terminal there is no 5-second rush, because you type each message yourself after connecting:
npx wscat -c ws://127.0.0.1:7777 -H "Origin: http://localhost:5173"
> { "type": "hello", "key": "PASTE_YOUR_KEY" }
> { "reqId": "1", "type": "chats.list" }Troubleshooting
| What you see | Cause |
| --- | --- |
| 403 Forbidden on connect | The Origin header is missing or doesn't exactly match the configured origin |
| Connection refused | The helper isn't running, or it's on a different port |
| Handshake timed out (close code 4001) | hello wasn't sent within 5 seconds of connecting |
| Invalid pairing key | Wrong key, or the key was rotated with reset-key. Run pair to get the current key |
| INVALID_FRAME | The message doesn't match the protocol (misspelled type, missing field) |
| INVALID_CWD | The cwd folder doesn't exist or isn't a directory |
| hello.ok with claude.available: false | Claude Code isn't installed or logged in. Run claude once |
License
MIT
