Plan — In Progress
A generic rokojori-ecosystem service at tunnel.rokojori.com that lets
authenticated users expose local services to the internet — with access control,
metadata, and discovery built in from the start.
Local tunneling solves one problem: your local machine is running something useful
(a local LLM, a Stable Diffusion instance, a language server) but it is only
reachable on localhost. The tunnel service makes it reachable over
the internet by routing traffic through a relay server.
What makes this different from existing tools (ngrok, localtunnel, pinggy) is identity. Every tunnel is owned by a rokojori user account. Access is controlled per-user — you can keep a tunnel private, share it with specific people, or open it to anyone with the link. Apps in the rokojori ecosystem can discover and use tunnels via a standard API, so a user can point Roject's AI agent at their own local LLM without configuring endpoints manually.
The service lives at tunnel.rokojori.com — a standalone Express app,
same stack as the rest of the ecosystem, verified via the shared JWT from
account.rokojori.com.
Traffic flows through three legs: browser/app → relay server → local agent → local service.
App (Roject on phone)
→ HTTPS → tunnel.rokojori.com/t/<tunnelId>/v1/chat/completions
→ WebSocket envelope → Electron agent (on the home machine)
→ HTTP → localhost:11434/v1/chat/completions
The relay server is a consensual man-in-the-middle. Both sides connect to it knowingly. The server's job is to forward traffic as transparently as possible — inspect only what is required for routing, pass everything else as raw bytes.
The server must read the HTTP request line (method, path) to strip the
/t/<tunnelId> prefix, and must rewrite the Host
header. Everything else — body bytes, SSE chunks, binary image data — is piped
through opaque without parsing.
The WebSocket connection between the server and the agent requires a minimal envelope to multiplex concurrent requests over one socket:
[4 bytes: requestId] [raw HTTP bytes...]
The agent strips the 4-byte prefix, forwards the remaining bytes to
localhost:<port>, and prepends the same requestId to the
response bytes before sending back. The server matches the response to the
waiting HTTP connection by requestId and flushes it.
Local LLMs stream tokens via Server-Sent Events or chunked transfer encoding. The agent forwards response bytes as they arrive — the relay server keeps the HTTP response to the app open and flushes each chunk immediately. No buffering, no full-response collection needed.
This means the app receives the stream in real time, exactly as it would if it were calling the local service directly.
The primary target services (Stable Diffusion AUTOMATIC1111, Ollama, LM Studio, OpenAI-compatible APIs) all speak HTTP with optional SSE streaming. None require WebSocket from the app side. Language servers (C# OmniSharp, Godot LSP) use raw TCP — a separate and harder problem, not planned for v1.
Only authenticated rokojori users in the tunnel's allowedUserIds
list (plus the owner) can send requests through it. The relay server verifies the
JWT on every inbound request. This is the default for new tunnels.
Anyone with the tunnel URL can send requests — no authentication required. Equivalent to what ngrok and localtunnel provide out of the box. Useful for quick demos or sharing with clients who have no rokojori account.
A single shared secret is required in an X-Tunnel-Key header or as
a query parameter. Simpler than full account management for trusted-but-anonymous
recipients. Not planned for v1.
Each tunnel carries metadata so that apps and users can discover, filter, and select tunnels without prior knowledge of what is running behind them.
{
"id": "uuid",
"name": "Josef's Ollama",
"description": "Local Ollama instance, llama3.2 and mistral available",
"purpose": "llm-openai-compatible",
"ownerId": "uuid",
"access": "private",
"allowedUserIds": ["uuid-alice", "uuid-bob"],
"localPort": 11434,
"active": true,
"createdAt": "2026-07-16T00:00:00Z"
}
The purpose field is a well-known tag that apps use to filter
relevant tunnels. Defined as a fixed set in the service:
llm-openai-compatible — drop-in replacement for the OpenAI
/v1/chat/completions endpoint; works with any OpenAI-compatible API
(Ollama, LM Studio, etc.)stable-diffusion — AUTOMATIC1111 or ComfyUI image generationgeneral — generic HTTP relay, no specific integration contractMore purpose tags are added as concrete integrations are built.
A tunnel can be registered (config saved, metadata available for discovery) while
the agent is offline. The active flag reflects whether the agent
WebSocket is currently connected. Apps should check this before attempting to
send requests through a tunnel.
A standalone Express + Node.js service. Owns tunnel configs (JSON file storage), the WebSocket server that agents connect to, and the HTTP proxy routes that apps call.
account.rokojori.com (shared JWT_SECRET)tunnel:create permission before allowing tunnel registrationbuild/data/db/tunnels.json
A small Electron desktop app that runs in the system tray. The user logs in once
via account.rokojori.com (same direct API call pattern as the Roject
Electron app). Tokens are stored in userData/tokens.json.
The user configures one or more tunnels — each with a name, purpose tag,
description, and local port. The app opens a persistent WebSocket to
wss://tunnel.rokojori.com/api/agent/:tunnelId for each active tunnel
and forwards inbound byte envelopes to the local port.
No tunnel logic lives in rokojori-auth. The only addition is a
tunnel:create permission added to the roles map. Users without this
permission are rejected by the relay server when attempting to register a tunnel.
POST /api/tunnels — register a new tunnel config (requires tunnel:create)
GET /api/tunnels — list tunnels owned by the requesting user
GET /api/tunnels/available — list tunnels the user has access to (owned + allowed)
GET /api/tunnels/available?purpose= — same, filtered by purpose tag
GET /api/tunnels/:id — get one tunnel's metadata
PATCH /api/tunnels/:id — update name, description, access, allowedUserIds
DELETE /api/tunnels/:id — remove tunnel config (owner only)
GET /api/agent/:tunnelId — WebSocket; agent connects here, stays open
On connection the server verifies the JWT, confirms the connecting user owns the tunnel, and registers the socket in the in-memory map. On disconnect the tunnel is marked inactive.
ALL /t/:tunnelId/* — relay any HTTP method to the agent
For private tunnels the JWT is verified and the requesting user must be the owner
or appear in allowedUserIds. For public tunnels no auth is required.
If the tunnel is registered but the agent is offline, the server returns
503 Service Unavailable.
Any app in the rokojori ecosystem can query the tunnel service for tunnels available to the current user, filtered by purpose. The app then uses the tunnel URL as a standard HTTP endpoint — no special tunnel SDK required on the app side.
When a user configures an AI agent or task in Roject, they choose an LLM provider. Providers come in two kinds:
tunnel.rokojori.com. The user clicks "Browse tunnels", Roject
fetches GET /api/tunnels/available?purpose=llm-openai-compatible
and shows a picker with each tunnel's name, description, owner, and active
status.
Once selected, Roject stores the tunnelId and constructs the
endpoint at runtime:
https://tunnel.rokojori.com/t/<tunnelId>/v1/chat/completions
From Roject's perspective this is a standard OpenAI-compatible HTTP endpoint. Streaming works identically — SSE chunks flow from the local LLM through the relay to the Roject UI in real time. No code in Roject knows or cares that a tunnel is involved.
If a tunnel's allowedUserIds includes other rokojori accounts,
those users see the tunnel in their /api/tunnels/available response
too — even though they do not own it. A household can share one Stable
Diffusion machine; a small team can share one GPU server. The owner adds user IDs
via the Electron app or a future web UI on tunnel.rokojori.com.
C:\rokojori\projects\web-projects\tunnel\
source/
server/
routes/
tunnels.ts — CRUD for tunnel configs
proxy.ts — ALL /t/:tunnelId/* relay handler
agent.ts — WebSocket upgrade endpoint for agents
middleware/
requireAuth.ts — JWT verification (shared pattern)
relay/
TunnelRegistry.ts — in-memory Map<tunnelId, AgentSocket>
db.ts — tunnel config storage (JSON files)
index.ts — Express entry point
electron-agent/
main.ts — Electron main process, tray setup
login-window.ts — direct API login, token storage
tray.ts — system tray icon and menu
agent/
TunnelAgent.ts — WebSocket connection + HTTP forwarding loop
AgentConfig.ts — local tunnel config (name, port, tunnelId)
build/
data/
db/
tunnels.json — registered tunnel configs
package.json
tsconfig.json
tsconfig.ts-node.json
.env
JWT_SECRET=... — same shared value as all other rokojori services
PORT=3002 — or whichever port nginx proxies to
C:\rokojori\projects\web-projects\tunnelscripts/test-agent.ts)gemma4-coding LLM on port 8900 and response returned correctlyGET /api/tunnels/available endpoint with purpose filteringtunnel.rokojori.com for managing tunnels without the desktop app