
{"id":5591,"date":"2026-07-12T19:12:23","date_gmt":"2026-07-12T19:12:23","guid":{"rendered":"https:\/\/blog.gordonbuchan.com\/blog\/?p=5591"},"modified":"2026-07-12T21:04:34","modified_gmt":"2026-07-12T21:04:34","slug":"installing-and-configuring-an-openhands-agent-connected-to-an-llm-inference-service","status":"publish","type":"post","link":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/2026\/07\/12\/installing-and-configuring-an-openhands-agent-connected-to-an-llm-inference-service\/","title":{"rendered":"Installing and configuring an OpenHands agent connected to an LLM inference service"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">In this procedure we install and configure an <a href=\"https:\/\/www.openhands.dev\/\" data-type=\"link\" data-id=\"https:\/\/www.openhands.dev\/\">OpenHands<\/a> agent connected to an LLM inference service. The OpenHands agent is hosted on a virtual machine (VM) under <a href=\"https:\/\/virt-manager.org\/\">virt-manager<\/a> and is connected to an inference service backend hosted by <a href=\"https:\/\/deepinfra.com\/\">DeepInfra<\/a>, serving the model <a href=\"https:\/\/deepinfra.com\/Qwen\/Qwen3.5-397B-A17B\">Qwen3.5 397B-A17B<\/a>, via OpenHands&#8217; built-in LiteLLM integration. Tested on <a href=\"https:\/\/releases.ubuntu.com\/noble\/\">Ubuntu Desktop 24.04 LTS (Noble Numbat)<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note re Ubuntu Desktop 26.04: At the time of this writing there was a bug in the remote desktop server, making Ubuntu Desktop 26.04 unsuitable for this exercise.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Understanding OpenHands<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><br>OpenHands is a coding agent designed for software engineering tasks: reading and editing files, running shell commands, writing and running tests, opening pull requests (PRs). It can also be given a procedure document and a target VM and asked to execute the installation via SSH from inside its sandbox.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">OpenHands web interface<\/h3>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"638\" src=\"https:\/\/blog.gordonbuchan.com\/blog\/wp-content\/uploads\/2026\/07\/image-1-1024x638.png\" alt=\"\" class=\"wp-image-5651\" srcset=\"https:\/\/blog.gordonbuchan.com\/blog\/wp-content\/uploads\/2026\/07\/image-1-1024x638.png 1024w, https:\/\/blog.gordonbuchan.com\/blog\/wp-content\/uploads\/2026\/07\/image-1-300x187.png 300w, https:\/\/blog.gordonbuchan.com\/blog\/wp-content\/uploads\/2026\/07\/image-1-768x479.png 768w, https:\/\/blog.gordonbuchan.com\/blog\/wp-content\/uploads\/2026\/07\/image-1.png 1394w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The architecture has two components. The controller process manages the agent loop and handles LLM abstraction via its built-in LiteLLM. The sandbox is a Docker container spawned per task: the agent&#8217;s code, shell commands, and file writes all run inside the sandbox, isolated from the host VM.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">KVM host          \u2192  openhands01  (Ubuntu Desktop VM)<br>                       \u251c\u2500 OpenHands controller  (port 3000, web UI)<br>                       \u2502    \u2514\u2500 Built-in LiteLLM \u2192 DeepInfra \u2192 Qwen3.5-397B-A17B<br>                       \u2514\u2500 Docker sandbox containers  (spawned per task)<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">OpenHands vs OpenClaw vs Hermes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/openclaw.ai\/\">OpenClaw<\/a>: sysadmin agent, browser automation, SSH deployments, approval gates.<br><a href=\"https:\/\/hermes-agent.nousresearch.com\/\">Hermes<\/a>: sysadmin agent, cron scheduling, persistent memory, SSH backends, reusable skills.<br><a href=\"https:\/\/www.openhands.dev\/\">OpenHands<\/a>: coding agent, file editing, test running, SSH deployments from sandbox. <a href=\"https:\/\/www.docker.com\/\">Docker<\/a> required. LiteLLM built in: no separate proxy needed. No skills system. No persistent memory across tasks. Reusable procedures are documents in the workspace directory.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Creating a DeepInfra account<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">OpenHands connects directly to DeepInfra through its built-in LiteLLM. You only need one API key.<br>Navigate to <a href=\"https:\/\/deepinfra.com\">https:\/\/deepinfra.com<\/a> and create an account.<br>Go to Dashboard \u2192 Billing \/ Credits \u2192 Add Credit. Add US $10.<br>Navigate to Dashboard \u2192 API Keys \u2192 Create API Key. Copy the key.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Verifying the key with curl<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">curl https:\/\/api.deepinfra.com\/v1\/openai\/chat\/completions \\<br>  -H 'Authorization: Bearer YOUR_DEEPINFRA_KEY' \\<br>  -H 'Content-Type: application\/json' \\<br>  -d '{<br>    \"model\": \"Qwen\/Qwen3.5-397B-A17B\",<br>    \"messages\": [{\"role\": \"user\", \"content\": \"Say hello in French.\"}]<br>  }'<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expected: valid JSON with a choices array. Do not proceed until this returns a valid response.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Creating the virtual machine<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In this procedure, <a href=\"https:\/\/www.openhands.dev\/\">OpenHands<\/a> runs inside an <a href=\"https:\/\/releases.ubuntu.com\/noble\/\">Ubuntu Desktop 24.04 Noble Numbat)<\/a> VM on a server running <a href=\"https:\/\/virt-manager.org\/\">virt-manager<\/a>\/KVM\/Qemu. You can use other virtualization platforms, such as <a href=\"https:\/\/vmware.com\">VMware<\/a>, <a href=\"https:\/\/learn.microsoft.com\/en-us\/windows-server\/virtualization\/hyper-v\/overview\">Hyper-V<\/a>, <a href=\"https:\/\/www.proxmox.com\/\">Proxmox<\/a>, Parallels, or a cloud platform (<a href=\"https:\/\/www.virtualbox.org\/\">VirtualBox<\/a> is not recommended for performance reasons). <a href=\"https:\/\/www.docker.com\/\">Docker<\/a> runs inside the VM. Ubuntu Desktop is used so you can watch the web UI on screen. The virtual machine is accessed by SSH and remote desktop software such as remmina.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Specifications for the virtual machine<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ubuntu Desktop 24.04 ISO.<br>RAM: 16384 MB, CPU: 4 vCPU, Disk: 100 GB.<br>Hardware: CPU model host-passthrough, Disk bus VirtIO, NIC VirtIO bridged, Display Spice or QXL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Notes: Configure the virtual machine with a static IP address, enable SSH and remote desktop access. enable auto-login and disable screen lock and screen timeouts. Hint: enter the command sudo ufw allow 3389\/tcp to expose the remote desktop through the virtual machine&#8217;s firewall.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Creating a snapshot (&#8220;fresh-install&#8221;)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a snapshot called fresh-install. for virt-manager, the commands from the hypervisor host are:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">(from the hypervisor host)<br><br>virsh shutdown openhands01 &amp;&amp; virsh domstate openhands01<br>virsh snapshot-create-as openhands01 fresh-install \\<br>  --description 'Clean Ubuntu Desktop, no packages' --atomic<br>virsh start openhands01<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Setting up the base OS<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Updating the system<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">sudo apt update<br>sudo apt full-upgrade -y<br>sudo reboot<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Installing dependencies<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">sudo apt install -y \\<br>  curl git jq htop tmux \\<br>  build-essential ca-certificates gnupg \\<br>  python3 python3-pip python3.12-venv \\<br>  openssh-client openssh-server \\<br>  qemu-guest-agent<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Installing Google Chrome<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Do not install chromium-browser via apt: it installs a snap stub. Install the official Chrome deb:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">wget https:\/\/dl.google.com\/linux\/direct\/google-chrome-stable_current_amd64.deb<br>sudo dpkg -i google-chrome-stable_current_amd64.deb<br>sudo apt --fix-broken install -y<br>google-chrome --version<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Enabling services and rebooting<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">sudo systemctl enable --now qemu-guest-agent<br>sudo systemctl enable --now ssh<br>sudo reboot<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Creating a snapshot (&#8220;base-os&#8221;)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a snapshot called base-os. For virt-manager, the commands from the hypervisor host are:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">(from the hypervisor)<br><br>virsh shutdown openhands01 &amp;&amp; virsh domstate openhands01<br>virsh snapshot-create-as openhands01 base-os \\<br>  --description 'Base OS packages installed, pre-Docker' --atomic<br>virsh start openhands01<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Installing Docker<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Docker is required. OpenHands spawns sandbox containers for each task via the Docker socket. Without it the agent loop fails immediately.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Installing Docker Engine<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">sudo install -m 0755 -d \/etc\/apt\/keyrings<br>curl -fsSL https:\/\/download.docker.com\/linux\/ubuntu\/gpg | \\<br>  sudo gpg --dearmor -o \/etc\/apt\/keyrings\/docker.gpg<br>sudo chmod a+r \/etc\/apt\/keyrings\/docker.gpg<br> <br>echo \\<br>  \"deb [arch=$(dpkg --print-architecture) \\<br>  signed-by=\/etc\/apt\/keyrings\/docker.gpg] \\<br>  https:\/\/download.docker.com\/linux\/ubuntu \\<br>  $(. \/etc\/os-release &amp;&amp; echo \"$VERSION_CODENAME\") stable\" | \\<br>  sudo tee \/etc\/apt\/sources.list.d\/docker.list &gt; \/dev\/null<br> <br>sudo apt update<br>sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin<br>sudo systemctl enable --now docker<br>sudo docker run hello-world<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Adding desktop to the docker group<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">sudo usermod -aG docker desktop<\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Log out and back in is required<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Docker group membership does not take effect in existing shell sessions. Log out of the desktop session completely and log back in. Verify with: docker run hello-world (no sudo). If you skip this step openhands serve will fail with permission denied on the Docker socket.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Creating a snapshot (&#8220;docker-installed&#8221;)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a snapshot called fresh-install. for virt-manager, the commands from the hypervisor host are:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">(from the hypervisor host)<br><br>virsh shutdown openhands01 &amp;&amp; virsh domstate openhands01<br>virsh snapshot-create-as openhands01 docker-installed \\<br>  --description 'Docker installed and verified' --atomic<br>virsh start openhands01<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Installing uv and OpenHands<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">OpenHands is installed via uv. Do not use pip install openhands-ai that is the legacy V0 SDK and will not work.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Installing uv<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">curl -LsSf https:\/\/astral.sh\/uv\/install.sh | sh<br>source ~\/.bashrc<br>uv --version<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Installing OpenHands<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">uv tool install openhands --python 3.12<br>openhands --version<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Creating workspace and SSH directories<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">mkdir -p ~\/openhands-workspace<br>mkdir -p ~\/.openhands<br>mkdir -p ~\/openhands-workspace\/.ssh<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Creating a snapshot (&#8220;post-install&#8221;)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a snapshot called base-os. For virt-manager, the commands from the hypervisor host are:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">(from the hypervisor)<br><br>virsh shutdown openhands01 &amp;&amp; virsh domstate openhands01<br>virsh snapshot-create-as openhands01 openhands-installed \\<br>  --description 'OpenHands installed via uv, pre-config' --atomic<br>virsh start openhands01<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Starting OpenHands for the first time<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Run openhands serve once manually to pull the Docker images. This must happen in an interactive terminal session before the autostart is configured.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">cd ~\/openhands-workspace<br>openhands serve<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">On first run this pulls the openhands Docker image: several gigabytes. It will appear to hang at Pulling for several minutes. Wait for:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">\u2705 Starting OpenHands GUI server...<br>The server will be available at: http:\/\/localhost:3000<\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Do not kill the process during image pull<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">The first run pulls several GB of Docker images. If it appears to hang at Pulling for 5-10 minutes this is normal. Wait for the startup confirmation before opening the web UI.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Open the web UI on the openhands01 desktop:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"><a href=\"http:\/\/localhost:3000\">http:\/\/localhost:3000<\/a><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Configuring the model<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The Settings page appears on first launch. Navigate to Settings \u2192 LLM \u2192 click the Advanced tab.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">How to access Settings<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">The gear icon in the sidebar navigates to a broken route (\/automations) in this version: ignore it. Access settings directly at: <a href=\"http:\/\/localhost:3000\/settings\">http:\/\/localhost:3000\/settings<\/a><\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Entering the model details<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Field<\/strong><\/td><td><strong>Value<\/strong><\/td><\/tr><tr><td>Custom Model<\/td><td>openai\/Qwen\/Qwen3.5-397B-A17B<\/td><\/tr><tr><td>Base URL<\/td><td>https:\/\/api.deepinfra.com\/v1\/openai<\/td><\/tr><tr><td>API Key<\/td><td>your_deepinfra_key_here<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Click Save Changes. Settings are stored in ~\/.openhands\/settings.json.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The openai\/ prefix is required<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">The openai\/ prefix tells LiteLLM to use the OpenAI-compatible request format. Without it, LiteLLM auto-detects the wrong format and produces cryptic 400 errors. Always enter openai\/Qwen\/Qwen3.5-397B-A17B.<br>The Basic tab shows named providers only: DeepInfra is not listed. The Advanced tab is the correct path.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Testing the connection<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Start a new conversation and send:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Say hello in French.<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expected: Bonjour. The first response may take 30-60 seconds while the sandbox container starts. If the agent produces no response at all, see next section.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Streaming troubleshooting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If the agent produces no response or an empty response, DeepInfra&#8217;s streaming implementation may be causing empty SSE responses: the same issue that affected OpenClaw and Hermes. Add disable_streaming = true to ~\/.openhands\/config.toml:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">nano ~\/.openhands\/config.toml<\/pre>\n\n\n\n<pre class=\"wp-block-preformatted\">[llm]<br>disable_streaming = true<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Restart OpenHands and test again. If disable_streaming = true resolves it, leave it in place, it has no effect on agent capability.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To restart:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"># Ctrl+C in the terminal running openhands serve, then:<br>openhands serve<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Persisting across reboots<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">openhands serve calls docker run -it which requires an interactive TTY. Neither rc.local nor systemd services provide a TTY at boot time: both fail with &#8216;cannot attach stdin to a TTY-enabled container&#8217;. The reliable solution for a Desktop VM accessed via RDP is a GNOME desktop autostart entry, which fires when the desktop session starts and has a proper TTY context.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Creating the autostart entry<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Stop openhands serve first (Ctrl+C), then:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">mkdir -p ~\/.config\/autostart<br>nano ~\/.config\/autostart\/openhands.desktop<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Paste the following:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">[Desktop Entry]<br>Type=Application<br>Name=OpenHands<br>Exec=\/home\/desktop\/.local\/bin\/openhands serve<br>Hidden=false<br>NoDisplay=false<br>X-GNOME-Autostart-enabled=true<\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Why desktop autostart and not rc.local or systemd<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">openhands serve uses docker run -it which requires a controlling terminal. rc.local and systemd both start without a terminal and fail immediately. The GNOME autostart mechanism runs after the desktop session initialises: it has a full terminal environment and docker run -it succeeds.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rebooting and verifying<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">sudo reboot<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Log back into openhands01 via RDP. Wait one minute for OpenHands to start automatically, then verify:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">docker ps | grep openhands<br>curl http:\/\/localhost:3000<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open the web UI at <a href=\"http:\/\/localhost:3000\">http:\/\/localhost:3000<\/a> and confirm the model responds.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Creating a snapshot (&#8220;post-rebooting&#8221;)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a snapshot called base-os. For virt-manager, the commands from the hypervisor host are:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">(from the hypervisor)<br><br>virsh shutdown openhands01 &amp;&amp; virsh domstate openhands01<br>virsh snapshot-create-as openhands01 openhands-configured \\<br>  --description 'OpenHands configured, working, reboot-verified' --atomic<br>virsh start openhands01<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Watching the browser work<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">OpenHands browser automation runs via Playwright inside the Docker sandbox container. There is no visible Chrome window on the openhands01 desktop: the sandbox has no display. All browser activity is observable through the web UI event log, where every navigation, click, and screenshot appears inline in the conversation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is no browser toggle in Settings,  browser capability is built into the sandbox and always available in this version.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Testing browser automation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Start a new conversation in the web UI and submit:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Go to https:\/\/example.com and tell me the page title and main heading.<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Watch the event log on the right side of the UI. You should see browser_navigate, browser_snapshot, and the page content returned inline.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">No visible Chrome window: this is expected<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">OpenHands browser automation is headless inside the Docker sandbox. This is different from Hermes and OpenClaw where Chrome opens visibly on the desktop. The web UI event log and inline screenshots give full visibility into what the agent is doing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Giving the sandbox SSH access to a remote VM<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When OpenHands SSHes to a remote VM, the connection runs from inside the sandbox container. The sandbox needs a key that the target trusts as root. Place the key inside the workspace directory: mounted into the sandbox at \/workspace: and authorize it on the target before starting the task.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Generating a dedicated key pair<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">ssh-keygen -t ed25519 -C 'openhands-sandbox' \\<br>  -f ~\/openhands-workspace\/.ssh\/openhands_sandbox<br># Press Enter twice: no passphrase<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Authorizing the key on the target VM<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">ssh-copy-id -i ~\/openhands-workspace\/.ssh\/openhands_sandbox.pub root@192.168.x.x<br> <br># Verify passwordless root access<br>ssh -i ~\/openhands-workspace\/.ssh\/openhands_sandbox root@192.168.x.x 'uname -a'<\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Root access via SSH key<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">The sandbox needs root on the target to install packages, configure services, and write to system directories. This key is scoped to OpenHands sandbox use: revoke it from authorized_keys on the target when the deployment is complete.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Pre-populating known_hosts<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Pre-populate to prevent SSH host key prompts stalling the agent mid-task:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">ssh-keyscan -H 192.168.x.x &gt;&gt; ~\/openhands-workspace\/.ssh\/known_hosts<br>chmod 600 ~\/openhands-workspace\/.ssh\/known_hosts<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Running a deployment task<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Provide to OpenHands a procedure document and a target VM and let it execute the installation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Placing the procedure in the workspace<\/h3>\n\n\n\n<pre class=\"wp-block-preformatted\">cp \/path\/to\/procedure.md ~\/openhands-workspace\/<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">The task prompt<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Submit this in the web UI: the prompt must be entirely self-contained since each sandbox starts fresh with no memory:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Read the procedure in \/workspace\/procedure.md.<br> <br>Connect to the target VM:<br>  Host: 192.168.x.x<br>  User: root<br>  SSH key: \/workspace\/.ssh\/openhands_sandbox<br>  Known hosts: \/workspace\/.ssh\/known_hosts<br> <br>Execute the procedure exactly as written.<br>Verify each step succeeds before continuing.<br>If a step fails, stop and report the error.<br> <br>When complete, provide a full status report:<br>what was installed, current state of each service,<br>and any issues encountered.<\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Self-contained prompts are mandatory<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Each OpenHands task starts in a fresh sandbox with no memory of prior sessions. Include the target host, SSH key path, and procedure reference in every prompt.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Running a first coding task<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Create a test file and give the agent a scoped coding task to verify the full stack:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">cat &gt; ~\/openhands-workspace\/utils.py &lt;&lt; 'EOF'<br>def add(a, b):<br>    return a + b<br> <br>def multiply(a, b):<br>    return a * b<br>EOF<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In the web UI, submit:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Add docstrings to both functions in utils.py.<br>Write a test file test_utils.py with pytest tests for both functions.<br>Run the tests and confirm they pass.<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Watch the event log. You should see the agent read utils.py, write docstrings, create test_utils.py, and run pytest inside the sandbox. Files are visible in ~\/openhands-workspace on the host after the task completes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Verifying that the system survives a reboot<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After the autostart is configured (section 9), reboot survival is automatic: log in via RDP and OpenHands starts. Confirm:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"># Docker starts on boot<br>systemctl is-enabled docker   # Expected: enabled<br> <br># Autostart entry is in place<br>cat ~\/.config\/autostart\/openhands.desktop<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">After any reboot, log back in via RDP, wait one minute, then:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">docker ps | grep openhands<br>curl http:\/\/localhost:3000<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Known issues and fixes<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Symptom<\/strong><\/td><td><strong>Fix<\/strong><\/td><\/tr><tr><td>Gear icon in sidebar shows 404 Not Found<\/td><td>The gear icon links to \/automations which does not exist in this version. Access settings at <a href=\"http:\/\/localhost:3000\/settings\">http:\/\/localhost:3000\/settings<\/a> instead.<\/td><\/tr><tr><td>openhands serve hangs at Pulling for several minutes<\/td><td>Normal on first run: the Docker image is several GB. Wait for the startup confirmation.<\/td><\/tr><tr><td>pip install openhands-ai works but openhands command not found<\/td><td>openhands-ai is the legacy V0 SDK. Use: uv tool install openhands &#8211;python 3.12<\/td><\/tr><tr><td>Docker permission denied without sudo<\/td><td>Run: sudo usermod -aG docker desktop: then log out and back in completely.<\/td><\/tr><tr><td>~\/.openhands owned by root, permission errors<\/td><td>Caused by a previous sudo openhands run. Fix: sudo chown desktop:desktop ~\/.openhands<\/td><\/tr><tr><td>Agent produces no response from DeepInfra<\/td><td>Add disable_streaming = true to the [llm] section of ~\/.openhands\/config.toml and restart openhands serve.<\/td><\/tr><tr><td>Cryptic 400 error from DeepInfra<\/td><td>Missing openai\/ prefix. Use openai\/Qwen\/Qwen3.5-397B-A17B not Qwen\/Qwen3.5-397B-A17B.<\/td><\/tr><tr><td>rc.local or systemd service fails with &#8216;cannot attach stdin to a TTY-enabled container&#8217;<\/td><td>openhands serve hardcodes docker run -it which requires a TTY. Neither rc.local nor systemd provides one. Use the GNOME desktop autostart approach in section 9 instead.<\/td><\/tr><tr><td>OpenHands does not start after reboot<\/td><td>Check the autostart entry exists: cat ~\/.config\/autostart\/openhands.desktop. The autostart only fires when you log into the desktop session via RDP: OpenHands is not running before login.<\/td><\/tr><tr><td>SSH host key prompt stalls the agent mid-task<\/td><td>Pre-populate known_hosts: ssh-keyscan -H &lt;target-ip&gt; &gt;&gt; ~\/openhands-workspace\/.ssh\/known_hosts<\/td><\/tr><tr><td>Sandbox cannot find SSH key<\/td><td>The key must be inside ~\/openhands-workspace\/ it is mounted at \/workspace\/ in the sandbox. Keys elsewhere are not visible to the agent.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Snapshot reference<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Snapshot<\/strong><\/td><td><strong>Created after<\/strong><\/td><\/tr><tr><td>fresh-install<\/td><td>Ubuntu Desktop installed, no packages<\/td><\/tr><tr><td>base-os<\/td><td>All apt packages installed, pre-Docker<\/td><\/tr><tr><td>docker-installed<\/td><td>Docker installed and verified<\/td><\/tr><tr><td>openhands-installed<\/td><td>OpenHands installed via uv, pre-config<\/td><\/tr><tr><td>openhands-configured<\/td><td>Model configured, working, reboot-verified<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Maintaining the system ongoing<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Task<\/strong><\/td><td><strong>Frequency<\/strong><\/td><\/tr><tr><td>Check DeepInfra dashboard for spend<\/td><td>Weekly<\/td><\/tr><tr><td>Rotate DeepInfra API key via Settings \u2192 LLM \u2192 Advanced, restart openhands serve<\/td><td>Every 90 days<\/td><\/tr><tr><td>Upgrade OpenHands: uv tool upgrade openhands check release notes first<\/td><td>Monthly<\/td><\/tr><tr><td>Prune unused Docker images: docker image prune -f<\/td><td>Monthly<\/td><\/tr><tr><td>Revoke sandbox SSH keys from target VMs after deployments complete<\/td><td>After each deployment<\/td><\/tr><tr><td>Snapshot openhands01 after any significant config change<\/td><td>As needed<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Daily usage<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><br>OpenHands starts automatically when the gnome desktop session is started at bootup with autologin. Open a browser on the openhands01 desktop and navigate to <a href=\"http:\/\/localhost:3000\">http:\/\/localhost:3000<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><br>To hand the agent a procedure document and a target VM:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"># Place the procedure in the workspace<br>cp procedure.md ~\/openhands-workspace\/<br> <br># Pre-populate known_hosts for the target<br>ssh-keyscan -H 192.168.x.x &gt;&gt; ~\/openhands-workspace\/.ssh\/known_hosts<br> <br># Open http:\/\/localhost:3000 and submit the task prompt<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For a project session:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">cd ~\/myproject<br>openhands serve --mount-cwd<\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">&#8211;mount-cwd for quick sessions<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">openhands serve &#8211;mount-cwd mounts the current directory into the sandbox as \/workspace. Use this for quick one-off sessions with a specific project without copying files into the workspace directory.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>In this procedure we install and configure an OpenHands agent connected to an LLM inference service. The OpenHands agent is hosted on a virtual machine (VM) under virt-manager and is connected to an inference service backend hosted by DeepInfra, serving the model Qwen3.5 397B-A17B, via OpenHands&#8217; built-in LiteLLM integration. Tested on Ubuntu Desktop 24.04 LTS &hellip; <\/p>\n<p class=\"link-more\"><a href=\"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/2026\/07\/12\/installing-and-configuring-an-openhands-agent-connected-to-an-llm-inference-service\/\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;Installing and configuring an OpenHands agent connected to an LLM inference service&#8221;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-5591","post","type-post","status-publish","format-standard","hentry","category-linux"],"_links":{"self":[{"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/posts\/5591","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/comments?post=5591"}],"version-history":[{"count":63,"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/posts\/5591\/revisions"}],"predecessor-version":[{"id":5657,"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/posts\/5591\/revisions\/5657"}],"wp:attachment":[{"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/media?parent=5591"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/categories?post=5591"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.gordonbuchan.com\/blog\/index.php\/wp-json\/wp\/v2\/tags?post=5591"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}