This document describes the implementation of Opcode's web server mode, which allows access to Claude Code from mobile devices and browsers while maintaining full functionality.
The web server provides a REST API and WebSocket interface that mirrors the Tauri desktop app's functionality, enabling phone/browser access to Claude Code sessions.
┌─────────────────┐ WebSocket ┌─────────────────┐ Process ┌─────────────────┐
│ Browser UI │ ←──────────────→ │ Rust Backend │ ────────────→ │ Claude Binary │
│ │ REST API │ (Axum Server) │ │ │
│ • React/TS │ ←──────────────→ │ │ │ • claude-code │
│ • WebSocket │ │ • Session Mgmt │ │ • Subprocess │
│ • DOM Events │ │ • Process Spawn │ │ • Stream Output │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Main Functions:
create_web_server()- Sets up Axum server with routesclaude_websocket_handler()- Manages WebSocket connectionsexecute_claude_command()/continue_claude_command()/resume_claude_command()- Execute Claude processesfind_claude_binary_web()- Locates Claude binary (bundled or system)
Key Features:
- WebSocket Streaming: Real-time output from Claude processes
- Session Management: Tracks active WebSocket sessions
- Process Spawning: Launches Claude subprocesses with proper arguments
- Comprehensive Logging: Detailed trace output for debugging
Dual Mode Support:
const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => {
// Web mode: Use DOM events
const domEventHandler = (event: any) => {
callback({ payload: event.detail });
};
window.addEventListener(eventName, domEventHandler);
return Promise.resolve(() => window.removeEventListener(eventName, domEventHandler));
});Message Processing:
- Handles both string payloads (Tauri) and object payloads (Web)
- Maintains compatibility with existing UI components
- Comprehensive error handling and logging
Request Format:
{
"command_type": "execute|continue|resume",
"project_path": "/path/to/project",
"prompt": "user prompt",
"model": "sonnet|opus",
"session_id": "uuid-for-resume"
}Response Format:
{
"type": "start|output|completion|error",
"content": "parsed Claude message",
"message": "status message",
"status": "success|error"
}Browser → WebSocket Request → Rust Backend → Claude Process
Claude Process → Rust Backend → WebSocket → Browser DOM Events → UI Update
- User Input: Prompt submitted via FloatingPromptInput
- WebSocket Send: JSON request sent to
/ws/claude - Process Spawn: Rust spawns
claudesubprocess - Stream Parse: Stdout lines parsed and wrapped in JSON
- Event Dispatch: DOM events fired for
claude-output - UI Update: React components receive and display messages
opcode/
├── src-tauri/src/
│ └── web_server.rs # Main web server implementation
├── src/
│ ├── lib/
│ │ └── apiAdapter.ts # WebSocket client & environment detection
│ └── components/
│ ├── ClaudeCodeSession.tsx # Main session component
│ └── claude-code-session/
│ └── useClaudeMessages.ts # Alternative hook implementation
└── justfile # Build configuration (just web)
nix-shell --run 'just web'
# Builds frontend and starts Rust server on port 8080- Binary Location: Checks bundled binary first, falls back to system PATH
- CORS: Configured for phone browser access
- Error Handling: Comprehensive logging and graceful failures
- Session Cleanup: Proper WebSocket session management
- Backend: All WebSocket events, process spawning, and message forwarding
- Frontend: Event setup, message parsing, and UI updates
- Process: Claude binary execution and output streaming
[TRACE] WebSocket handler started - session_id: uuid
[TRACE] Successfully parsed request: {...}
[TRACE] Claude process spawned successfully
[TRACE] Forwarding message to WebSocket: {...}
[TRACE] DOM event received: claude-output {...}
[TRACE] handleStreamMessage - message type: assistant
Problem: Original code only worked with Tauri events
Solution: Enhanced listen function to support DOM events in web mode
Problem: Backend sent JSON strings, frontend expected parsed objects
Solution: Parse content field in WebSocket handler before dispatching events
Problem: Web mode lacked Claude binary execution Solution: Full subprocess spawning with proper argument passing and output streaming
Problem: No state tracking for multiple concurrent sessions Solution: HashMap-based session tracking with proper cleanup
Problem: Frontend expected cancel and output endpoints that didn't exist
Solution: Added /api/sessions/{sessionId}/cancel and /api/sessions/{sessionId}/output endpoints
Problem: WebSocket errors and unexpected closures didn't dispatch UI events
Solution: Added claude-error and claude-complete event dispatching for all error scenarios
Problem: The UI expects session-specific events like claude-output:${sessionId} but the backend only dispatches generic events like claude-output.
Current Backend Behavior:
// Only dispatches generic events
window.dispatchEvent(new CustomEvent('claude-output', { detail: claudeMessage }));
window.dispatchEvent(new CustomEvent('claude-complete', { detail: success }));
window.dispatchEvent(new CustomEvent('claude-error', { detail: error }));Frontend Expectations:
// Expects session-scoped events
await listen(`claude-output:${sessionId}`, handleOutput);
await listen(`claude-error:${sessionId}`, handleError);
await listen(`claude-complete:${sessionId}`, handleComplete);Impact: Session isolation doesn't work - all sessions receive all events.
Problem: The cancel endpoint is just a stub that doesn't actually terminate running Claude processes.
Current Implementation:
async fn cancel_claude_execution(Path(sessionId): Path<String>) -> Json<ApiResponse<()>> {
// Just logs - doesn't actually cancel anything
println!("[TRACE] Cancel request for session: {}", sessionId);
Json(ApiResponse::success(()))
}Missing:
- Process tracking and storage in session state
- Actual process termination via
kill()or process handles - Proper cleanup of WebSocket sessions on cancellation
- Session-specific process management
Problem: Claude processes can write errors to stderr, but the web server only captures stdout.
Current: Only child.stdout is captured and streamed
Missing: child.stderr capture and claude-error event emission
Problem: The Tauri implementation emits claude-cancelled events but the web server doesn't.
Tauri Implementation:
let _ = app.emit(&format!("claude-cancelled:{}", sid), true);
let _ = app.emit("claude-cancelled", true);Web Server: No claude-cancelled events are dispatched.
Problem: The web server generates its own session IDs but doesn't map them to the frontend's session IDs.
Current: WebSocket handler creates uuid::Uuid::new_v4().to_string() but frontend passes sessionId in request.
Missing: Proper session ID mapping and tracking.
-
Session-Scoped Event Dispatching
- Modify
apiAdapter.tsto dispatch both generic and session-specific events - Update WebSocket handler to use the frontend's sessionId instead of generating new ones
- Ensure events like
claude-output:${sessionId}are dispatched correctly
- Modify
-
Process Management and Cancellation
- Add process handle storage to AppState
- Implement actual process termination in
cancel_claude_execution - Add proper cleanup on WebSocket disconnection
-
stderr Handling
- Capture both stdout and stderr in Claude process execution
- Emit
claude-errorevents for stderr content - Properly handle process error states
-
claude-cancelled Events
- Add
claude-cancelledevent dispatching for consistency with Tauri - Implement proper cancellation flow matching desktop behavior
- Add
- Session ID Mapping
- Use frontend-provided sessionId consistently
- Remove UUID generation in WebSocket handler
- Ensure session tracking works correctly
The web server should dispatch both generic and session-specific events to match Tauri:
// Both events should be dispatched
window.dispatchEvent(new CustomEvent('claude-output', { detail: claudeMessage }));
window.dispatchEvent(new CustomEvent(`claude-output:${sessionId}`, { detail: claudeMessage }));The AppState should track process handles:
pub struct AppState {
pub active_sessions: Arc<Mutex<HashMap<String, tokio::sync::mpsc::Sender<String>>>>,
pub active_processes: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
}- Streaming: Real-time output without buffering delays
- Memory: Proper cleanup of completed sessions
- Concurrency: Multiple WebSocket connections supported
- Error Recovery: Graceful handling of process failures
- Binary Execution: Uses
--dangerously-skip-permissionsflag for web mode - CORS: Allows all origins for development (should be restricted in production)
- Process Isolation: Each session runs in separate subprocess
- Input Validation: JSON parsing with error handling
- Authentication: Add user authentication for production deployment
- Rate Limiting: Prevent abuse of Claude API calls
- Session Persistence: Save/restore session state across reconnections
- Mobile Optimization: Enhanced UI for mobile browsers
- Error Recovery: Automatic reconnection on WebSocket failures
- Process Monitoring: Add process health checks and automatic restart
- Concurrent Session Limits: Limit number of concurrent Claude processes
- File Management: Add file upload/download capabilities for web mode
- Advanced Logging: Structured logging with log levels and rotation
- Start web server:
nix-shell --run 'just web' - Open browser to
http://localhost:8080 - Select project directory
- Send prompt and verify streaming response
- Check browser console for trace output
- Browser DevTools: WebSocket messages and console logs
- Server Logs: Rust trace output for backend debugging
- Network Tab: REST API calls and WebSocket traffic
- No Claude Binary: Check PATH or install Claude Code
- WebSocket Errors: Verify server is running and accessible
- Event Not Received: Check DOM event listeners in browser console
- Process Spawn Failure: Verify project path and permissions
- Session Events Not Working: Check if session-scoped events are being dispatched (critical issue)
- Cancel Button Doesn't Work: Process cancellation not implemented yet (critical issue)
- Multiple Sessions Interfere: Generic events cause cross-session interference
- Errors Not Displayed: stderr not captured, only stdout is shown
# Check Claude binary
which claude
# Test WebSocket endpoint
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: test" -H "Sec-WebSocket-Version: 13" \
http://localhost:8080/ws/claude
# Monitor server logs
tail -f server.log # if logging to fileThe web server implementation provides basic functionality but has critical issues that prevent full feature parity with the Tauri desktop app:
- WebSocket-based Claude execution with streaming output
- Basic session management and tracking
- REST API endpoints for most functionality
- Comprehensive debugging and tracing
- Error handling for WebSocket failures
- Basic process spawning and output capture
- Session-scoped event dispatching: Sessions interfere with each other
- Process cancellation: Cancel button doesn't actually terminate processes
- stderr handling: Error messages from Claude not displayed
- claude-cancelled events: Missing cancellation event support
The web server is functional for single-session use but not suitable for production due to the session isolation issues. Multiple concurrent sessions will interfere with each other, and users cannot cancel running processes.
- Fix session-scoped event dispatching (highest priority)
- Implement proper process management and cancellation
- Add stderr capture and error event emission
- Test with multiple concurrent sessions
This implementation successfully bridges the gap between Tauri desktop and web deployment, but requires the above fixes to achieve full feature parity while adapting to browser constraints.