diff --git a/AGENTS.md b/AGENTS.md
index 05a6742d4..d5a548cde 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,7 +32,7 @@ hermes-agent/
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
-│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
+│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity, koyeb)
├── gateway/ # Messaging gateway — run.py + session.py + platforms/
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 146cb1161..be87ed831 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -157,7 +157,7 @@ hermes-agent/
│ ├── skill_tools.py # Skill search, load, manage
│ └── environments/ # Terminal execution backends
│ ├── base.py # BaseEnvironment ABC
-│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py
+│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py, koyeb.py
│
├── gateway/ # Messaging gateway
│ ├── run.py # GatewayRunner — platform lifecycle, message routing, cron
diff --git a/README.md b/README.md
index 11390fb2b..3eecb9a78 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
| A closed learning loop | Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. |
-| Runs anywhere, not just your laptop | Six terminal backends — local, Docker, SSH, Daytona, Singularity, and Modal. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
+| Runs anywhere, not just your laptop | Seven terminal backends — local, Docker, SSH, Daytona, Singularity, Modal, and Koyeb. Daytona, Modal, and Koyeb offer serverless cloud sandboxes — your agent's environment spins up on demand and is deleted when done. Run it on a $5 VPS or a GPU cluster. |
| Research-ready | Batch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models. |
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 977acbe92..581b79103 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -226,8 +226,23 @@ terminal:
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# container_disk: 10240 # Daytona max is 10GB per sandbox
+# -----------------------------------------------------------------------------
+# OPTION 7: Koyeb cloud execution
+# Commands run in Koyeb cloud sandboxes
+# Great for: Serverless execution, global deployment, GPU access
+# Requires: pip install koyeb-sdk, KOYEB_API_TOKEN env var
+# -----------------------------------------------------------------------------
+# terminal:
+# backend: "koyeb"
+# cwd: "/root"
+# timeout: 180
+# lifetime_seconds: 300
+# koyeb_image: "nikolaik/python-nodejs:python3.11-nodejs20"
+# koyeb_instance_type: "micro" # Options: micro, small, medium, large, xlarge, 2xlarge
+# koyeb_region: "na" # Options: na, eu, fr, etc.
+
#
-# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) ---
+# --- Container resource limits (docker, singularity, modal, daytona, koyeb -- ignored for local/ssh) ---
# These settings apply to all container backends. They control the resources
# allocated to the sandbox and whether its filesystem persists across sessions.
container_cpu: 1 # CPU cores
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 7678287a0..b03ad2266 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -437,7 +437,8 @@ DEFAULT_CONFIG = {
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
- # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
+ "koyeb_image": "koyeb/sandbox:latest",
+ # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh/koyeb)
"container_cpu": 1,
"container_memory": 5120, # MB (default 5GB)
"container_disk": 51200, # MB (default 50GB)
@@ -3700,6 +3701,10 @@ def show_config():
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
daytona_key = get_env_value('DAYTONA_API_KEY')
print(f" API key: {'configured' if daytona_key else '(not set)'}")
+ elif terminal.get('backend') == 'koyeb':
+ print(f" Koyeb image: {terminal.get('koyeb_image', 'koyeb/sandbox:latest')}")
+ koyeb_token = get_env_value('KOYEB_API_TOKEN')
+ print(f" API token: {'configured' if koyeb_token else '(not set)'}")
elif terminal.get('backend') == 'ssh':
ssh_host = get_env_value('TERMINAL_SSH_HOST')
ssh_user = get_env_value('TERMINAL_SSH_USER')
@@ -3892,6 +3897,7 @@ def set_config_value(key: str, value: str):
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
+ "terminal.koyeb_image": "TERMINAL_KOYEB_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.cwd": "TERMINAL_CWD",
"terminal.timeout": "TERMINAL_TIMEOUT",
diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py
index cba4ebcdd..ab12acf2b 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -795,6 +795,21 @@ def run_doctor(args):
check_fail("daytona SDK not installed", "(pip install daytona)")
issues.append("Install daytona SDK: pip install daytona")
+ # Koyeb (if using koyeb backend)
+ if terminal_env == "koyeb":
+ koyeb_token = os.getenv("KOYEB_API_TOKEN")
+ if koyeb_token:
+ check_ok("Koyeb API token", "(configured)")
+ else:
+ check_fail("KOYEB_API_TOKEN not set", "(required for TERMINAL_ENV=koyeb)")
+ issues.append("Set KOYEB_API_TOKEN environment variable")
+ try:
+ from koyeb import Sandbox # noqa: F401 — SDK presence check
+ check_ok("koyeb SDK", "(installed)")
+ except ImportError:
+ check_fail("koyeb SDK not installed", "(pip install koyeb-sdk)")
+ issues.append("Install koyeb SDK: pip install koyeb-sdk")
+
# Node.js + agent-browser (for browser automation tools)
if shutil.which("node"):
check_ok("Node.js")
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index e28acd41b..dde603821 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -1191,11 +1191,12 @@ def setup_terminal_backend(config: dict):
"Modal - serverless cloud sandbox",
"SSH - run on a remote machine",
"Daytona - persistent cloud development environment",
+ "Koyeb - cloud sandbox execution",
]
- idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
- backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
+ idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "koyeb"}
+ backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "koyeb": 5}
- next_idx = 5
+ next_idx = 6
if is_linux:
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
idx_to_backend[next_idx] = "singularity"
@@ -1450,6 +1451,64 @@ def setup_terminal_backend(config: dict):
_prompt_container_resources(config)
+ elif selected_backend == "koyeb":
+ print_success("Terminal backend: Koyeb")
+ print_info("Cloud sandbox execution via Koyeb.")
+ print_info("Sign up at: https://www.koyeb.com")
+
+ # Check if koyeb SDK is installed
+ try:
+ __import__("koyeb")
+ except ImportError:
+ print_info("Installing koyeb SDK...")
+ import subprocess
+
+ uv_bin = shutil.which("uv")
+ if uv_bin:
+ result = subprocess.run(
+ [uv_bin, "pip", "install", "--python", sys.executable, "koyeb-sdk"],
+ capture_output=True,
+ text=True,
+ )
+ else:
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "koyeb-sdk"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ print_success("koyeb SDK installed")
+ else:
+ print_warning("Install failed — run manually: pip install koyeb-sdk")
+ if result.stderr:
+ print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
+
+ # Koyeb API token
+ print()
+ existing_key = get_env_value("KOYEB_API_TOKEN")
+ if existing_key:
+ print_info(" Koyeb API token: already configured")
+ if prompt_yes_no(" Update API token?", False):
+ api_key = prompt(" Koyeb API token", password=True)
+ if api_key:
+ save_env_value("KOYEB_API_TOKEN", api_key)
+ print_success(" Updated")
+ else:
+ api_key = prompt(" Koyeb API token", password=True)
+ if api_key:
+ save_env_value("KOYEB_API_TOKEN", api_key)
+ print_success(" Configured")
+
+ # Koyeb image
+ current_image = config.get("terminal", {}).get(
+ "koyeb_image", "koyeb/sandbox:latest"
+ )
+ image = prompt(" Sandbox image", current_image)
+ config["terminal"]["koyeb_image"] = image
+ save_env_value("TERMINAL_KOYEB_IMAGE", image)
+
+ _prompt_container_resources(config)
+
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
print_info("Run commands on a remote machine via SSH.")
diff --git a/pyproject.toml b/pyproject.toml
index 4b7e8816a..1c9ae5e57 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,6 +39,7 @@ dependencies = [
[project.optional-dependencies]
modal = ["modal>=1.0.0,<2"]
daytona = ["daytona>=0.148.0,<1"]
+koyeb = ["koyeb-sdk>=1.4.0,<2"]
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"]
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
cron = ["croniter>=6.0.0,<7"]
diff --git a/tests/integration/test_koyeb_terminal.py b/tests/integration/test_koyeb_terminal.py
new file mode 100644
index 000000000..91724b1f2
--- /dev/null
+++ b/tests/integration/test_koyeb_terminal.py
@@ -0,0 +1,258 @@
+#!/usr/bin/env python3
+"""
+Test Koyeb Terminal Tool
+
+This script tests that the Koyeb terminal backend is correctly configured
+and can execute commands in Koyeb sandboxes.
+
+Usage:
+ # Run with Koyeb backend
+ TERMINAL_ENV=koyeb python tests/test_koyeb_terminal.py
+
+ # Or run directly (will use whatever TERMINAL_ENV is set in .env)
+ python tests/test_koyeb_terminal.py
+"""
+
+import pytest
+pytestmark = pytest.mark.integration
+
+import os
+import sys
+import json
+from pathlib import Path
+
+# Try to load .env file if python-dotenv is available
+try:
+ from dotenv import load_dotenv
+ load_dotenv()
+except ImportError:
+ # Manually load .env if dotenv not available
+ env_file = Path(__file__).parent.parent.parent / ".env"
+ if env_file.exists():
+ with open(env_file) as f:
+ for line in f:
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ key, value = line.split('=', 1)
+ # Remove quotes if present
+ value = value.strip().strip('"').strip("'")
+ os.environ.setdefault(key.strip(), value)
+
+# Add project root to path for imports
+parent_dir = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(parent_dir))
+
+# Import terminal_tool module directly using importlib to avoid tools/__init__.py
+import importlib.util
+terminal_tool_path = parent_dir / "tools" / "terminal_tool.py"
+spec = importlib.util.spec_from_file_location("terminal_tool", terminal_tool_path)
+terminal_module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(terminal_module)
+
+terminal_tool = terminal_module.terminal_tool
+check_terminal_requirements = terminal_module.check_terminal_requirements
+_get_env_config = terminal_module._get_env_config
+cleanup_vm = terminal_module.cleanup_vm
+
+
+def test_koyeb_requirements():
+ """Test that Koyeb requirements are met."""
+ print("\n" + "=" * 60)
+ print("TEST 1: Koyeb Requirements Check")
+ print("=" * 60)
+
+ config = _get_env_config()
+ print(f"Current TERMINAL_ENV: {config['env_type']}")
+ print(f"Koyeb image: {config['koyeb_image']}")
+
+ # Check for Koyeb authentication
+ koyeb_token = os.getenv("KOYEB_API_TOKEN")
+
+ print(f"\nKoyeb authentication:")
+ print(f" KOYEB_API_TOKEN env var: {'✅ Set' if koyeb_token else '❌ Not set'}")
+
+ if config['env_type'] != 'koyeb':
+ print(f"\n⚠️ TERMINAL_ENV is '{config['env_type']}', not 'koyeb'")
+ print(" Set TERMINAL_ENV=koyeb in .env or export it to test Koyeb backend")
+ return False
+
+ requirements_met = check_terminal_requirements()
+ print(f"\nRequirements check: {'✅ Passed' if requirements_met else '❌ Failed'}")
+
+ return requirements_met
+
+
+def test_simple_command():
+ """Test executing a simple command."""
+ print("\n" + "=" * 60)
+ print("TEST 2: Simple Command Execution")
+ print("=" * 60)
+
+ test_task_id = "koyeb_test_simple"
+
+ print("Executing: echo 'Hello from Koyeb!'")
+ result = terminal_tool("echo 'Hello from Koyeb!'", task_id=test_task_id)
+ result_json = json.loads(result)
+
+ print(f"\nResult:")
+ print(f" Output: {result_json.get('output', '')[:200]}")
+ print(f" Exit code: {result_json.get('exit_code')}")
+ print(f" Error: {result_json.get('error')}")
+
+ success = result_json.get('exit_code') == 0 and 'Hello from Koyeb!' in result_json.get('output', '')
+ print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}")
+
+ # Cleanup
+ cleanup_vm(test_task_id)
+
+ return success
+
+
+def test_python_execution():
+ """Test executing Python code in Koyeb."""
+ print("\n" + "=" * 60)
+ print("TEST 3: Python Execution")
+ print("=" * 60)
+
+ test_task_id = "koyeb_test_python"
+
+ python_cmd = 'python3 -c "import sys; print(f\'Python {sys.version}\')"'
+ print(f"Executing: {python_cmd}")
+
+ result = terminal_tool(python_cmd, task_id=test_task_id)
+ result_json = json.loads(result)
+
+ print(f"\nResult:")
+ print(f" Output: {result_json.get('output', '')[:200]}")
+ print(f" Exit code: {result_json.get('exit_code')}")
+ print(f" Error: {result_json.get('error')}")
+
+ success = result_json.get('exit_code') == 0 and 'Python' in result_json.get('output', '')
+ print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}")
+
+ # Cleanup
+ cleanup_vm(test_task_id)
+
+ return success
+
+
+def test_filesystem_operations():
+ """Test filesystem operations in Koyeb."""
+ print("\n" + "=" * 60)
+ print("TEST 4: Filesystem Operations")
+ print("=" * 60)
+
+ test_task_id = "koyeb_test_fs"
+
+ # Create a file
+ print("Step 1: Creating test file...")
+ result1 = terminal_tool("echo 'koyeb filesystem test' > /tmp/koyeb_test.txt", task_id=test_task_id)
+ result1_json = json.loads(result1)
+ print(f" Exit code: {result1_json.get('exit_code')}")
+
+ # Read the file back
+ print("Step 2: Reading test file...")
+ result2 = terminal_tool("cat /tmp/koyeb_test.txt", task_id=test_task_id)
+ result2_json = json.loads(result2)
+ print(f" Output: {result2_json.get('output', '')}")
+ print(f" Exit code: {result2_json.get('exit_code')}")
+
+ success = (
+ result1_json.get('exit_code') == 0 and
+ result2_json.get('exit_code') == 0 and
+ 'koyeb filesystem test' in result2_json.get('output', '')
+ )
+ print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}")
+
+ # Cleanup
+ cleanup_vm(test_task_id)
+
+ return success
+
+
+def test_environment_isolation():
+ """Test that different task_ids get isolated environments."""
+ print("\n" + "=" * 60)
+ print("TEST 5: Environment Isolation")
+ print("=" * 60)
+
+ task1 = "koyeb_test_iso_1"
+ task2 = "koyeb_test_iso_2"
+
+ # Create file in task1
+ print("Step 1: Creating file in task1...")
+ result1 = terminal_tool("echo 'task1 data' > /tmp/isolated.txt", task_id=task1)
+
+ # Try to read from task2 (should not exist)
+ print("Step 2: Trying to read file from task2 (should not exist)...")
+ result2 = terminal_tool("cat /tmp/isolated.txt 2>&1 || echo 'FILE_NOT_FOUND'", task_id=task2)
+ result2_json = json.loads(result2)
+
+ # The file should either not exist or be empty in task2
+ output = result2_json.get('output', '')
+ isolated = 'task1 data' not in output or 'FILE_NOT_FOUND' in output or 'No such file' in output
+
+ print(f" Task2 output: {output[:200]}")
+ print(f"\nTest: {'✅ Passed (environments isolated)' if isolated else '❌ Failed (environments NOT isolated)'}")
+
+ # Cleanup
+ cleanup_vm(task1)
+ cleanup_vm(task2)
+
+ return isolated
+
+
+def main():
+ """Run all Koyeb terminal tests."""
+ print("🧪 Koyeb Terminal Tool Test Suite")
+ print("=" * 60)
+
+ # Check current config
+ config = _get_env_config()
+ print(f"\nCurrent configuration:")
+ print(f" TERMINAL_ENV: {config['env_type']}")
+ print(f" TERMINAL_KOYEB_IMAGE: {config['koyeb_image']}")
+ print(f" TERMINAL_TIMEOUT: {config['timeout']}s")
+
+ if config['env_type'] != 'koyeb':
+ print(f"\n⚠️ WARNING: TERMINAL_ENV is set to '{config['env_type']}', not 'koyeb'")
+ print(" To test Koyeb specifically, set TERMINAL_ENV=koyeb")
+ response = input("\n Continue testing with current backend? (y/n): ")
+ if response.lower() != 'y':
+ print("Aborting.")
+ return
+
+ results = {}
+
+ # Run tests
+ results['requirements'] = test_koyeb_requirements()
+
+ if not results['requirements']:
+ print("\n❌ Requirements not met. Cannot continue with other tests.")
+ return
+
+ results['simple_command'] = test_simple_command()
+ results['python_execution'] = test_python_execution()
+ results['filesystem_operations'] = test_filesystem_operations()
+ results['environment_isolation'] = test_environment_isolation()
+
+ # Summary
+ print("\n" + "=" * 60)
+ print("TEST SUMMARY")
+ print("=" * 60)
+
+ passed = sum(1 for v in results.values() if v)
+ total = len(results)
+
+ for test_name, passed_test in results.items():
+ status = "✅ PASSED" if passed_test else "❌ FAILED"
+ print(f" {test_name}: {status}")
+
+ print(f"\nTotal: {passed}/{total} tests passed")
+
+ return passed == total
+
+
+if __name__ == "__main__":
+ success = main()
+ sys.exit(0 if success else 1)
diff --git a/tests/tools/test_koyeb_environment.py b/tests/tools/test_koyeb_environment.py
new file mode 100644
index 000000000..3bd32bf5d
--- /dev/null
+++ b/tests/tools/test_koyeb_environment.py
@@ -0,0 +1,266 @@
+"""Unit tests for the Koyeb cloud sandbox environment backend."""
+
+import threading
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# Helpers to build mock Koyeb SDK objects
+# ---------------------------------------------------------------------------
+
+def _make_exec_response(stdout="", stderr="", exit_code=0):
+ return SimpleNamespace(stdout=stdout, stderr=stderr, exit_code=exit_code)
+
+
+def _make_sandbox(sandbox_id="sb-koyeb-123"):
+ sb = MagicMock()
+ sb.id = sandbox_id
+ sb.exec.return_value = _make_exec_response()
+ sb.filesystem = MagicMock()
+ return sb
+
+
+def _patch_koyeb_imports(monkeypatch):
+ """Patch the koyeb SDK so KoyebEnvironment can be imported without it."""
+ import types as _types
+
+ koyeb_mod = _types.ModuleType("koyeb")
+ koyeb_mod.Sandbox = MagicMock()
+
+ monkeypatch.setitem(__import__("sys").modules, "koyeb", koyeb_mod)
+ return koyeb_mod
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture()
+def koyeb_sdk(monkeypatch):
+ """Provide a mock koyeb SDK module and return it for assertions."""
+ return _patch_koyeb_imports(monkeypatch)
+
+
+@pytest.fixture()
+def make_env(koyeb_sdk, monkeypatch):
+ """Factory that creates a KoyebEnvironment with a mocked SDK."""
+ monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False)
+ monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
+ monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None)
+ monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: [])
+
+ def _factory(
+ sandbox=None,
+ home_dir="/root",
+ **kwargs,
+ ):
+ sandbox = sandbox or _make_sandbox()
+ # Mock the $HOME detection
+ sandbox.exec.return_value = _make_exec_response(stdout=home_dir)
+
+ koyeb_sdk.Sandbox.create.return_value = sandbox
+
+ from tools.environments.koyeb import KoyebEnvironment
+
+ kwargs.setdefault("task_id", "test-task")
+ env = KoyebEnvironment(
+ image="koyeb/sandbox:latest",
+ **kwargs,
+ )
+ return env
+
+ return _factory
+
+
+# ---------------------------------------------------------------------------
+# Constructor / cwd resolution
+# ---------------------------------------------------------------------------
+
+class TestCwdResolution:
+ def test_default_cwd_resolves_home(self, make_env):
+ env = make_env(home_dir="/home/testuser")
+ assert env.cwd == "/home/testuser"
+
+ def test_tilde_cwd_resolves_home(self, make_env):
+ env = make_env(cwd="~", home_dir="/home/testuser")
+ assert env.cwd == "/home/testuser"
+
+ def test_explicit_cwd_not_overridden(self, make_env):
+ env = make_env(cwd="/workspace", home_dir="/root")
+ assert env.cwd == "/workspace"
+
+ def test_home_detection_failure_keeps_default_cwd(self, make_env):
+ sb = _make_sandbox()
+ sb.exec.side_effect = RuntimeError("exec failed")
+ env = make_env(sandbox=sb)
+ assert env.cwd == "/root" # keeps constructor default
+
+ def test_empty_home_keeps_default_cwd(self, make_env):
+ env = make_env(home_dir="")
+ assert env.cwd == "/root"
+
+
+# ---------------------------------------------------------------------------
+# Sandbox name sanitization
+# ---------------------------------------------------------------------------
+
+class TestSandboxNameSanitization:
+ def test_underscores_replaced_with_hyphens(self, make_env, koyeb_sdk):
+ make_env(task_id="my_test_task")
+ name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
+ assert "_" not in name_arg
+ assert name_arg == "hermes-my-test-task"
+
+ def test_uppercase_lowered(self, make_env, koyeb_sdk):
+ make_env(task_id="MyTask")
+ name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
+ assert name_arg == "hermes-mytask"
+
+ def test_special_chars_removed(self, make_env, koyeb_sdk):
+ make_env(task_id="task@#$123")
+ name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
+ assert name_arg == "hermes-task-123"
+
+ def test_name_truncated_to_63_chars(self, make_env, koyeb_sdk):
+ make_env(task_id="a" * 100)
+ name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
+ assert len(name_arg) <= 63
+
+ def test_consecutive_hyphens_collapsed(self, make_env, koyeb_sdk):
+ make_env(task_id="a__b---c")
+ name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
+ assert "--" not in name_arg
+
+
+# ---------------------------------------------------------------------------
+# Cleanup
+# ---------------------------------------------------------------------------
+
+class TestCleanup:
+ def test_cleanup_deletes_sandbox(self, make_env):
+ env = make_env()
+ sb = env._sandbox
+ env.cleanup()
+ sb.delete.assert_called_once()
+
+ def test_cleanup_idempotent(self, make_env):
+ env = make_env()
+ env.cleanup()
+ env.cleanup() # should not raise
+
+ def test_cleanup_swallows_errors(self, make_env):
+ env = make_env()
+ env._sandbox.delete.side_effect = RuntimeError("delete failed")
+ env.cleanup() # should not raise
+ assert env._sandbox is None
+
+ def test_cleanup_calls_sync_back_before_delete(self, make_env):
+ env = make_env()
+ call_order = []
+ sync_mgr = MagicMock()
+ sync_mgr.sync_back = lambda: call_order.append("sync_back")
+ env._sync_manager = sync_mgr
+ original_delete = env._sandbox.delete
+ env._sandbox.delete = lambda: (call_order.append("delete"), original_delete())
+
+ env.cleanup()
+
+ assert "sync_back" in call_order
+ assert "delete" in call_order
+ assert call_order.index("sync_back") < call_order.index("delete")
+
+
+# ---------------------------------------------------------------------------
+# Execute
+# ---------------------------------------------------------------------------
+
+class TestExecute:
+ def test_basic_command(self, make_env):
+ sb = _make_sandbox()
+ # Calls: (1) $HOME detection, (2) init_session bootstrap, (3) actual command
+ sb.exec.side_effect = [
+ _make_exec_response(stdout="/root"), # $HOME
+ _make_exec_response(stdout="", exit_code=0), # init_session
+ _make_exec_response(stdout="hello", exit_code=0), # actual cmd
+ ]
+ env = make_env(sandbox=sb)
+
+ result = env.execute("echo hello")
+ assert "hello" in result["output"]
+ assert result["returncode"] == 0
+
+ def test_nonzero_exit_code(self, make_env):
+ sb = _make_sandbox()
+ sb.exec.side_effect = [
+ _make_exec_response(stdout="/root"),
+ _make_exec_response(stdout="", exit_code=0), # init_session
+ _make_exec_response(stdout="not found", exit_code=127),
+ ]
+ env = make_env(sandbox=sb)
+
+ result = env.execute("bad_cmd")
+ assert result["returncode"] == 127
+
+ def test_stderr_included_in_output(self, make_env):
+ sb = _make_sandbox()
+ sb.exec.side_effect = [
+ _make_exec_response(stdout="/root"),
+ _make_exec_response(stdout="", exit_code=0), # init_session
+ _make_exec_response(stdout="out", stderr="err", exit_code=0),
+ ]
+ env = make_env(sandbox=sb)
+
+ result = env.execute("cmd")
+ assert "out" in result["output"]
+ assert "err" in result["output"]
+
+ def test_stdin_data_wraps_heredoc(self, make_env):
+ sb = _make_sandbox()
+ sb.exec.side_effect = [
+ _make_exec_response(stdout="/root"),
+ _make_exec_response(stdout="", exit_code=0), # init_session
+ _make_exec_response(stdout="ok", exit_code=0),
+ ]
+ env = make_env(sandbox=sb)
+
+ env.execute("python3", stdin_data="print('hi')")
+ call_args = sb.exec.call_args_list[-1]
+ cmd = call_args[0][0]
+ assert "HERMES_STDIN_" in cmd
+ assert "print" in cmd
+
+
+# ---------------------------------------------------------------------------
+# Interrupt
+# ---------------------------------------------------------------------------
+
+class TestInterrupt:
+ def test_interrupt_kills_and_returns_130(self, make_env, monkeypatch):
+ sb = _make_sandbox()
+ event = threading.Event()
+ calls = {"n": 0}
+
+ def exec_side_effect(*args, **kwargs):
+ calls["n"] += 1
+ if calls["n"] == 1:
+ return _make_exec_response(stdout="/root") # $HOME
+ if calls["n"] == 2:
+ return _make_exec_response(stdout="", exit_code=0) # init_session
+ event.wait(timeout=5) # simulate long-running command
+ return _make_exec_response(stdout="done", exit_code=0)
+
+ sb.exec.side_effect = exec_side_effect
+ env = make_env(sandbox=sb)
+
+ monkeypatch.setattr(
+ "tools.environments.base.is_interrupted", lambda: True
+ )
+ try:
+ result = env.execute("sleep 10")
+ assert result["returncode"] == 130
+ sb.delete.assert_called() # cancel_fn calls sandbox.delete()
+ finally:
+ event.set()
diff --git a/tests/tools/test_sync_back_backends.py b/tests/tools/test_sync_back_backends.py
index 97bec17e2..9f79d5c87 100644
--- a/tests/tools/test_sync_back_backends.py
+++ b/tests/tools/test_sync_back_backends.py
@@ -10,6 +10,7 @@ import pytest
from tools.environments import ssh as ssh_env
from tools.environments import modal as modal_env
from tools.environments import daytona as daytona_env
+from tools.environments import koyeb as koyeb_env
from tools.environments.ssh import SSHEnvironment
@@ -95,6 +96,20 @@ def _make_mock_daytona_env():
return env
+# ── Koyeb helpers ────────────────────────────────────────────────────
+
+
+def _make_mock_koyeb_env():
+ """Create a minimal KoyebEnvironment without calling __init__."""
+ env = object.__new__(koyeb_env.KoyebEnvironment)
+ env._sandbox = MagicMock()
+ env._remote_home = "/root"
+ env._sync_manager = None
+ env._lock = __import__("threading").Lock()
+ env._task_id = "test"
+ return env
+
+
# =====================================================================
# SSH bulk download
# =====================================================================
@@ -402,6 +417,69 @@ class TestDaytonaCleanup:
assert call_order.index("sync_back") < call_order.index("stop")
+# =====================================================================
+# Koyeb bulk download + cleanup
+# =====================================================================
+
+
+class TestKoyebBulkDownload:
+ """Unit tests for _koyeb_bulk_download."""
+
+ def test_koyeb_bulk_download_creates_tar_and_downloads(self, tmp_path):
+ """exec and download_file should both be called."""
+ env = _make_mock_koyeb_env()
+ dest = tmp_path / "backup.tar"
+
+ env._koyeb_bulk_download(dest)
+
+ # exec called twice: tar creation + rm cleanup
+ assert env._sandbox.exec.call_count == 2
+ tar_cmd = env._sandbox.exec.call_args_list[0][0][0]
+ assert "tar cf" in tar_cmd
+ assert "/tmp/.hermes_sync." in tar_cmd
+ assert ".tar" in tar_cmd
+ assert ".hermes" in tar_cmd
+
+ cleanup_cmd = env._sandbox.exec.call_args_list[1][0][0]
+ assert "rm -f" in cleanup_cmd
+
+ env._sandbox.filesystem.download_file.assert_called_once()
+ download_args = env._sandbox.filesystem.download_file.call_args[0]
+ assert download_args[0].startswith("/tmp/.hermes_sync.")
+ assert download_args[1] == str(dest)
+
+ def test_koyeb_bulk_download_uses_remote_home(self, tmp_path):
+ """The tar command should use the env's _remote_home."""
+ env = _make_mock_koyeb_env()
+ env._remote_home = "/home/koyeb"
+ dest = tmp_path / "backup.tar"
+
+ env._koyeb_bulk_download(dest)
+
+ tar_cmd = env._sandbox.exec.call_args_list[0][0][0]
+ assert "home/koyeb/.hermes" in tar_cmd
+
+
+class TestKoyebCleanup:
+ """Verify Koyeb cleanup() calls sync_back() before delete."""
+
+ def test_koyeb_cleanup_calls_sync_back(self):
+ """cleanup() should call sync_back() before sandbox.delete()."""
+ env = _make_mock_koyeb_env()
+
+ call_order = []
+ sync_mgr = MagicMock()
+ sync_mgr.sync_back = lambda: call_order.append("sync_back")
+ env._sync_manager = sync_mgr
+ env._sandbox.delete = lambda: call_order.append("delete")
+
+ env.cleanup()
+
+ assert "sync_back" in call_order
+ assert "delete" in call_order
+ assert call_order.index("sync_back") < call_order.index("delete")
+
+
# =====================================================================
# FileSyncManager wiring: bulk_download_fn passed by each backend
# =====================================================================
diff --git a/tools/environments/__init__.py b/tools/environments/__init__.py
index 7ffcce1c6..2f338bf53 100644
--- a/tools/environments/__init__.py
+++ b/tools/environments/__init__.py
@@ -2,7 +2,7 @@
Each backend provides the same interface (BaseEnvironment ABC) for running
shell commands in a specific execution context: local, Docker, Singularity,
-SSH, Modal, or Daytona.
+SSH, Modal, Daytona, or Koyeb.
The terminal_tool.py factory (_create_environment) selects the backend
based on the TERMINAL_ENV configuration.
diff --git a/tools/environments/koyeb.py b/tools/environments/koyeb.py
new file mode 100644
index 000000000..7fd5ec7fe
--- /dev/null
+++ b/tools/environments/koyeb.py
@@ -0,0 +1,231 @@
+"""Koyeb cloud execution environment.
+
+Uses the Koyeb Python SDK to run commands in cloud sandboxes.
+Each task gets its own sandbox which is deleted on cleanup.
+"""
+
+import logging
+import math
+import os
+import re
+import shlex
+import threading
+from pathlib import Path
+from typing import Any
+
+from tools.environments.base import (
+ BaseEnvironment,
+ _ThreadedProcessHandle,
+)
+from tools.environments.file_sync import (
+ FileSyncManager,
+ iter_sync_files,
+ quoted_mkdir_command,
+ quoted_rm_command,
+ unique_parent_dirs,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class KoyebEnvironment(BaseEnvironment):
+ """Koyeb cloud sandbox execution backend.
+
+ Spawn-per-call via _ThreadedProcessHandle wrapping blocking SDK calls.
+ cancel_fn wired to sandbox.delete() for interrupt support.
+ Shell timeout wrapper preserved (SDK timeout unreliable).
+ """
+
+ _stdin_mode = "heredoc"
+
+ def __init__(
+ self,
+ image: str,
+ cwd: str = "/root",
+ timeout: int = 60,
+ cpu: int = 1,
+ memory: int = 5120,
+ disk: int = 10240,
+ persistent_filesystem: bool = True,
+ task_id: str = "default",
+ instance_type: str = "micro",
+ region: str = None,
+ ):
+ requested_cwd = cwd
+ super().__init__(cwd=cwd, timeout=timeout)
+
+ from koyeb import Sandbox
+
+ self._task_id = task_id
+ self._sandbox = None
+ self._lock = threading.Lock()
+ self._instance_type = instance_type
+ self._region = region or os.getenv("KOYEB_REGION", "na")
+ self._api_token = os.getenv("KOYEB_API_TOKEN")
+
+ # Convert memory from MB to GB (Koyeb uses GB)
+ memory_gib = max(1, math.ceil(memory / 1024))
+
+ # Koyeb instance types: micro, small, medium, large, xlarge, 2xlarge, etc.
+ # For now, we'll use the instance_type parameter directly
+ # cpu and memory parameters are kept for compatibility but may be overridden by instance_type
+
+ # Koyeb app names must be lowercase alphanumeric + hyphens only.
+ # Sanitize task_id: replace underscores/invalid chars with hyphens,
+ # collapse runs, strip leading/trailing hyphens, and truncate.
+ safe_id = re.sub(r"[^a-z0-9-]", "-", task_id.lower())
+ safe_id = re.sub(r"-{2,}", "-", safe_id).strip("-")
+ sandbox_name = f"hermes-{safe_id}"[:63] # Koyeb name max length
+ try:
+ self._sandbox = Sandbox.create(
+ image=image,
+ name=sandbox_name,
+ wait_ready=True,
+ instance_type=self._instance_type,
+ region=self._region,
+ api_token=self._api_token,
+ timeout=300,
+ )
+ logger.info("Koyeb: created sandbox %s for task %s",
+ self._sandbox.id, task_id)
+ except Exception as e:
+ logger.error("Koyeb: failed to create sandbox: %s", e)
+ raise
+
+ # Detect remote home dir
+ self._remote_home = "/root"
+ try:
+ home = self._sandbox.exec("echo $HOME").stdout.strip()
+ if home:
+ self._remote_home = home
+ if requested_cwd in ("~", "/root"):
+ self.cwd = home
+ except Exception:
+ pass
+ logger.info("Koyeb: resolved home to %s, cwd to %s", self._remote_home, self.cwd)
+
+ self._sync_manager = FileSyncManager(
+ get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"),
+ upload_fn=self._koyeb_upload,
+ delete_fn=self._koyeb_delete,
+ bulk_upload_fn=self._koyeb_bulk_upload,
+ bulk_download_fn=self._koyeb_bulk_download,
+ )
+ self._sync_manager.sync(force=True)
+ self.init_session()
+
+ def _koyeb_upload(self, host_path: str, remote_path: str) -> None:
+ """Upload a single file via Koyeb SDK."""
+ parent = str(Path(remote_path).parent)
+ self._sandbox.exec(f"mkdir -p {shlex.quote(parent)}")
+ self._sandbox.filesystem.upload_file(host_path, remote_path, encoding="base64")
+
+ def _koyeb_bulk_upload(self, files: list[tuple[str, str]]) -> None:
+ """Upload many files as a single tar archive to avoid per-file HTTP overhead."""
+ if not files:
+ return
+
+ import tarfile
+ import tempfile
+
+ with tempfile.NamedTemporaryFile(suffix=".tar", delete=False) as tmp:
+ tmp_path = tmp.name
+
+ try:
+ with tarfile.open(tmp_path, "w") as tar:
+ for host_path, remote_path in files:
+ # Store with absolute remote path inside the tar
+ tar.add(host_path, arcname=remote_path)
+
+ remote_tar = f"/tmp/.hermes_upload.{os.getpid()}.tar"
+ self._sandbox.filesystem.upload_file(tmp_path, remote_tar, encoding="base64")
+ self._sandbox.exec(f"tar xf {shlex.quote(remote_tar)} -C / && rm -f {shlex.quote(remote_tar)}")
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+ def _koyeb_bulk_download(self, dest: Path) -> None:
+ """Download remote .hermes/ as a tar archive."""
+ rel_base = f"{self._remote_home}/.hermes".lstrip("/")
+ # PID-suffixed remote temp path avoids collisions if sync_back fires
+ # concurrently for the same sandbox (e.g. retry after partial failure).
+ remote_tar = f"/tmp/.hermes_sync.{os.getpid()}.tar"
+ self._sandbox.exec(
+ f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(rel_base)}"
+ )
+ self._sandbox.filesystem.download_file(remote_tar, str(dest))
+ # Clean up remote temp file
+ try:
+ self._sandbox.exec(f"rm -f {shlex.quote(remote_tar)}")
+ except Exception:
+ pass # best-effort cleanup
+
+ def _koyeb_delete(self, remote_paths: list[str]) -> None:
+ """Batch-delete remote files via SDK exec."""
+ self._sandbox.exec(quoted_rm_command(remote_paths))
+
+ # ------------------------------------------------------------------
+ # Sandbox lifecycle
+ # ------------------------------------------------------------------
+
+ def _ensure_sandbox_ready(self) -> None:
+ """Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
+ # Koyeb sandboxes don't have a stopped state like Daytona
+ # They're either running or need to be recreated
+ pass
+
+ def _before_execute(self) -> None:
+ """Ensure sandbox is ready, then sync files via FileSyncManager."""
+ with self._lock:
+ self._ensure_sandbox_ready()
+ self._sync_manager.sync()
+
+ def _run_bash(self, cmd_string: str, *, login: bool = False,
+ timeout: int = 120,
+ stdin_data: str | None = None):
+ """Return a _ThreadedProcessHandle wrapping a blocking Koyeb SDK call."""
+ sandbox = self._sandbox
+ lock = self._lock
+
+ def cancel():
+ with lock:
+ try:
+ sandbox.delete()
+ except Exception:
+ pass
+
+ if login:
+ shell_cmd = f"bash -l -c {shlex.quote(cmd_string)}"
+ else:
+ shell_cmd = f"bash -c {shlex.quote(cmd_string)}"
+
+ def exec_fn() -> tuple[str, int]:
+ result = sandbox.exec(shell_cmd, timeout=timeout)
+ output = result.stdout or ""
+ if result.stderr:
+ output = f"{output}\n{result.stderr}" if output else result.stderr
+ return (output, result.exit_code)
+
+ return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
+
+ def cleanup(self):
+ with self._lock:
+ if self._sandbox is None:
+ return
+
+ # Sync remote changes back to host before teardown
+ if self._sync_manager:
+ logger.info("Koyeb: syncing files from sandbox...")
+ try:
+ self._sync_manager.sync_back()
+ except Exception as e:
+ logger.warning("Koyeb: sync_back failed: %s", e)
+
+ try:
+ self._sandbox.delete()
+ logger.info("Koyeb: deleted sandbox %s", self._sandbox.id)
+ except Exception as e:
+ logger.warning("Koyeb: cleanup failed: %s", e)
+ self._sandbox = None
diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py
index b288d4ad9..1f0eccbe6 100644
--- a/tools/terminal_tool.py
+++ b/tools/terminal_tool.py
@@ -2,16 +2,17 @@
"""
Terminal Tool Module
-A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments.
-Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode.
+A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, Daytona, and Koyeb environments.
+Supports local execution, containerized backends, and cloud sandboxes (Modal, Daytona, Koyeb), including managed gateway mode.
Environment Selection (via TERMINAL_ENV environment variable):
- "local": Execute directly on the host machine (default, fastest)
- "docker": Execute in Docker containers (isolated, requires Docker)
- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
+- "koyeb": Execute in Koyeb cloud sandboxes
Features:
-- Multiple execution backends (local, docker, modal)
+- Multiple execution backends (local, docker, modal, koyeb)
- Background task support
- VM/container lifecycle management
- Automatic cleanup after inactivity
@@ -855,7 +856,7 @@ def _get_env_config() -> Dict[str, Any]:
):
host_cwd = candidate
cwd = "/workspace"
- elif env_type in ("modal", "docker", "singularity", "daytona") and cwd:
+ elif env_type in ("modal", "docker", "singularity", "daytona", "koyeb") and cwd:
# Host paths and relative paths that won't work inside containers
is_host_path = any(cwd.startswith(p) for p in host_prefixes)
is_relative = not os.path.isabs(cwd) # e.g. "." or "src/"
@@ -873,6 +874,7 @@ def _get_env_config() -> Dict[str, Any]:
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
+ "koyeb_image": os.getenv("TERMINAL_KOYEB_IMAGE", default_image),
"cwd": cwd,
"host_cwd": host_cwd,
"docker_mount_cwd_to_workspace": mount_docker_cwd,
@@ -891,12 +893,15 @@ def _get_env_config() -> Dict[str, Any]:
os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
).lower() in ("true", "1", "yes"),
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"),
- # Container resource config (applies to docker, singularity, modal, daytona -- ignored for local/ssh)
+ # Container resource config (applies to docker, singularity, modal, daytona, koyeb -- ignored for local/ssh)
"container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
"container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
"container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"),
"docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"),
+ # Koyeb-specific config
+ "koyeb_instance_type": os.getenv("TERMINAL_KOYEB_INSTANCE_TYPE", "micro"),
+ "koyeb_region": os.getenv("KOYEB_REGION", "na"),
}
@@ -918,8 +923,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
Create an execution environment for sandboxed command execution.
Args:
- env_type: One of "local", "docker", "singularity", "modal", "daytona", "ssh"
- image: Docker/Singularity/Modal image name (ignored for local/ssh)
+ env_type: One of "local", "docker", "singularity", "modal", "daytona", "koyeb", "ssh"
+ image: Docker/Singularity/Modal/Koyeb image name (ignored for local/ssh)
cwd: Working directory
timeout: Default command timeout
ssh_config: SSH connection config (for env_type="ssh")
@@ -1022,6 +1027,22 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
persistent_filesystem=persistent, task_id=task_id,
)
+ elif env_type == "koyeb":
+ # Lazy import so koyeb SDK is only required when backend is selected.
+ from tools.environments.koyeb import KoyebEnvironment as _KoyebEnvironment
+
+ # Get Koyeb-specific configuration
+ koyeb_instance_type = cc.get("koyeb_instance_type", "micro")
+ koyeb_region = cc.get("koyeb_region")
+
+ return _KoyebEnvironment(
+ image=image, cwd=cwd, timeout=timeout,
+ cpu=int(cpu), memory=memory, disk=disk,
+ persistent_filesystem=persistent, task_id=task_id,
+ instance_type=koyeb_instance_type,
+ region=koyeb_region,
+ )
+
elif env_type == "ssh":
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
@@ -1035,7 +1056,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
)
else:
- raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', or 'ssh'")
+ raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', 'koyeb', or 'ssh'")
def _cleanup_inactive_envs(lifetime_seconds: int = 300):
@@ -1462,6 +1483,8 @@ def terminal_tool(
image = overrides.get("modal_image") or config["modal_image"]
elif env_type == "daytona":
image = overrides.get("daytona_image") or config["daytona_image"]
+ elif env_type == "koyeb":
+ image = overrides.get("koyeb_image") or config["koyeb_image"]
else:
image = ""
@@ -1538,7 +1561,7 @@ def terminal_tool(
}
container_config = None
- if env_type in ("docker", "singularity", "modal", "daytona"):
+ if env_type in ("docker", "singularity", "modal", "daytona", "koyeb"):
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
@@ -1948,10 +1971,14 @@ def check_terminal_requirements() -> bool:
from daytona import Daytona # noqa: F401 — SDK presence check
return os.getenv("DAYTONA_API_KEY") is not None
+ elif env_type == "koyeb":
+ from koyeb import Sandbox # noqa: F401 — SDK presence check
+ return os.getenv("KOYEB_API_TOKEN") is not None
+
else:
logger.error(
"Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, "
- "modal, daytona, ssh.",
+ "modal, daytona, koyeb, ssh.",
env_type,
)
return False
@@ -1970,6 +1997,7 @@ if __name__ == "__main__":
print(f" Environment type: {config['env_type']}")
print(f" Docker image: {config['docker_image']}")
print(f" Modal image: {config['modal_image']}")
+ print(f" Koyeb image: {config['koyeb_image']}")
print(f" Working directory: {config['cwd']}")
print(f" Default timeout: {config['timeout']}s")
print(f" Lifetime: {config['lifetime_seconds']}s")
@@ -1991,11 +2019,12 @@ if __name__ == "__main__":
print("\nEnvironment Variables:")
default_img = "nikolaik/python-nodejs:python3.11-nodejs20"
- print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/ssh)")
+ print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/koyeb/ssh)")
print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}")
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
+ print(f" TERMINAL_KOYEB_IMAGE: {os.getenv('TERMINAL_KOYEB_IMAGE', default_img)}")
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}")
from hermes_constants import display_hermes_home as _dhh
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}")
diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md
index 17e883081..43b0411d5 100644
--- a/website/docs/developer-guide/architecture.md
+++ b/website/docs/developer-guide/architecture.md
@@ -106,7 +106,7 @@ hermes-agent/
│ ├── credential_files.py # File-based credential passthrough
│ ├── env_passthrough.py # Env var passthrough for sandboxes
│ ├── ansi_strip.py # ANSI escape stripping
-│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
+│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity, koyeb)
│
├── gateway/ # Messaging platform gateway
│ ├── run.py # GatewayRunner — message dispatch (~9,000 lines)
diff --git a/website/docs/developer-guide/environments.md b/website/docs/developer-guide/environments.md
index 3409f3047..0ec1968c7 100644
--- a/website/docs/developer-guide/environments.md
+++ b/website/docs/developer-guide/environments.md
@@ -84,7 +84,7 @@ The foundation from `atroposlib`. Provides:
### HermesAgentBaseEnv
The hermes-agent layer (`environments/hermes_base_env.py`). Adds:
-- **Terminal backend configuration** — sets `TERMINAL_ENV` for sandboxed execution (local, Docker, Modal, Daytona, SSH, Singularity)
+- **Terminal backend configuration** — sets `TERMINAL_ENV` for sandboxed execution (local, Docker, Modal, Daytona, SSH, Singularity, Koyeb)
- **Tool resolution** — `_resolve_tools_for_group()` calls hermes-agent's `get_tool_definitions()` to get the right tool schemas based on enabled/disabled toolsets
- **Agent loop integration** — `collect_trajectory()` runs `HermesAgentLoop` and scores the result
- **Two-phase operation** — Phase 1 (OpenAI server) for eval/SFT, Phase 2 (VLLM ManagedServer) for full RL with logprobs
@@ -426,7 +426,7 @@ See `environments/benchmarks/yc_bench/yc_bench_env.py` for a clean, well-documen
| `max_agent_turns` | `int` | `30` | Max LLM calls per rollout |
| `agent_temperature` | `float` | `1.0` | Sampling temperature |
| `system_prompt` | `str` | `None` | System message for the agent |
-| `terminal_backend` | `str` | `"local"` | `local`, `docker`, `modal`, `daytona`, `ssh`, `singularity` |
+| `terminal_backend` | `str` | `"local"` | `local`, `docker`, `modal`, `daytona`, `ssh`, `singularity`, `koyeb` |
| `terminal_timeout` | `int` | `120` | Seconds per terminal command |
| `terminal_lifetime` | `int` | `3600` | Max sandbox lifetime |
| `dataset_name` | `str` | `None` | HuggingFace dataset identifier |
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index 42280b4df..2755d228e 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -125,6 +125,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `TINKER_API_KEY` | RL training ([tinker-console.thinkingmachines.ai](https://tinker-console.thinkingmachines.ai/)) |
| `WANDB_API_KEY` | RL training metrics ([wandb.ai](https://wandb.ai/)) |
| `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) |
+| `KOYEB_API_TOKEN` | Koyeb cloud sandboxes ([koyeb.com](https://www.koyeb.com/)) |
### Nous Tool Gateway
@@ -141,7 +142,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate
| Variable | Description |
|----------|-------------|
-| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` |
+| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona`, `koyeb` |
| `TERMINAL_DOCKER_IMAGE` | Docker image (default: `nikolaik/python-nodejs:python3.11-nodejs20`) |
| `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions. Note: skill-declared `required_environment_variables` are forwarded automatically — you only need this for vars not declared by any skill. |
| `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) |
@@ -149,6 +150,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate
| `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path |
| `TERMINAL_MODAL_IMAGE` | Modal container image |
| `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image |
+| `TERMINAL_KOYEB_IMAGE` | Koyeb sandbox image |
| `TERMINAL_TIMEOUT` | Command timeout in seconds |
| `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds |
| `TERMINAL_CWD` | Working directory for all terminal sessions |
@@ -166,7 +168,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `TERMINAL_SSH_KEY` | Path to private key |
| `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH (default: follows `TERMINAL_PERSISTENT_SHELL`) |
-## Container Resources (Docker, Singularity, Modal, Daytona)
+## Container Resources (Docker, Singularity, Modal, Daytona, Koyeb)
| Variable | Description |
|----------|-------------|
@@ -176,6 +178,13 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `TERMINAL_CONTAINER_PERSISTENT` | Persist container filesystem across sessions (default: `true`) |
| `TERMINAL_SANDBOX_DIR` | Host directory for workspaces and overlays (default: `~/.hermes/sandboxes/`) |
+## Koyeb Backend
+
+| Variable | Description |
+|----------|-------------|
+| `TERMINAL_KOYEB_INSTANCE_TYPE` | Koyeb instance type (default: `micro`). Options: `micro`, `small`, `medium`, `large`, `xlarge`, `2xlarge`, etc. |
+| `KOYEB_REGION` | Koyeb region (default: `na`). Options: `na`, `eu`, `fr`, etc. |
+
## Persistent Shell
| Variable | Description |
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index 80f5c6f88..517c50d64 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -83,17 +83,18 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM
## Terminal Backend Configuration
-Hermes supports six terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox, a Daytona workspace, or a Singularity/Apptainer container.
+Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox, a Daytona workspace, a Singularity/Apptainer container, or a Koyeb cloud sandbox.
```yaml
terminal:
- backend: local # local | docker | ssh | modal | daytona | singularity
+ backend: local # local | docker | ssh | modal | daytona | singularity | koyeb
cwd: "." # Working directory ("." = current dir for local, "/root" for containers)
timeout: 180 # Per-command timeout in seconds
env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code)
singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend
modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Modal backend
daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend
+ koyeb_image: "koyeb/sandbox:latest" # Container image for Koyeb backend
```
For cloud sandboxes such as Modal and Daytona, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later.
@@ -108,6 +109,7 @@ For cloud sandboxes such as Modal and Daytona, `container_persistent: true` mean
| **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals |
| **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments |
| **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines |
+| **koyeb** | Koyeb cloud sandbox | Full (cloud sandbox) | Serverless cloud execution |
### Local Backend
@@ -242,6 +244,28 @@ terminal:
**Isolation:** Uses `--containall --no-home` for full namespace isolation without mounting the host home directory.
+### Koyeb Backend
+
+Runs commands in a [Koyeb](https://www.koyeb.com) cloud sandbox. Each task gets an isolated sandbox that is deleted on cleanup.
+
+```yaml
+terminal:
+ backend: koyeb
+ koyeb_image: "koyeb/sandbox:latest"
+```
+
+**Required:** `KOYEB_API_TOKEN` environment variable. Get one from the [Koyeb control panel](https://app.koyeb.com/account/api).
+
+**Install the SDK:**
+
+```bash
+pip install "hermes-agent[koyeb]"
+```
+
+**Lifecycle:** A new sandbox is created for each task and deleted when the agent cleans up. There is no persistent state between sessions.
+
+**File sync:** `~/.hermes/` credentials and skills are synced to the sandbox via a single tarball upload for fast initialization.
+
### Common Terminal Backend Issues
If terminal commands fail immediately or the terminal tool is reported as disabled:
@@ -252,6 +276,7 @@ If terminal commands fail immediately or the terminal tool is reported as disabl
- **Modal** — Needs `MODAL_TOKEN_ID` env var or `~/.modal.toml`. Run `hermes doctor` to check.
- **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration.
- **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters.
+- **Koyeb** — Needs `KOYEB_API_TOKEN` and `pip install koyeb-sdk`. Run `hermes doctor` to check.
When in doubt, set `terminal.backend` back to `local` and verify that commands run there first.
diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md
index 2283c16fb..4174e80ad 100644
--- a/website/docs/user-guide/features/tools.md
+++ b/website/docs/user-guide/features/tools.md
@@ -64,13 +64,14 @@ The terminal tool can execute commands in different environments:
| `singularity` | HPC containers | Cluster computing, rootless |
| `modal` | Cloud execution | Serverless, scale |
| `daytona` | Cloud sandbox workspace | Persistent remote dev environments |
+| `koyeb` | Koyeb cloud sandbox | Serverless cloud execution |
### Configuration
```yaml
# In ~/.hermes/config.yaml
terminal:
- backend: local # or: docker, ssh, singularity, modal, daytona
+ backend: local # or: docker, ssh, singularity, modal, daytona, koyeb
cwd: "." # Working directory
timeout: 180 # Command timeout in seconds
```
@@ -123,7 +124,7 @@ Configure CPU, memory, disk, and persistence for all container backends:
```yaml
terminal:
- backend: docker # or singularity, modal, daytona
+ backend: docker # or singularity, modal, daytona, koyeb
container_cpu: 1 # CPU cores (default: 1)
container_memory: 5120 # Memory in MB (default: 5GB)
container_disk: 51200 # Disk in MB (default: 50GB)