From a346fd2d981d34e42f8c65e05282e3688cd75532 Mon Sep 17 00:00:00 2001
From: "openhands-release-bot[bot]"
<290150379+openhands-release-bot[bot]@users.noreply.github.com>
Date: Wed, 7 Oct 2026 15:51:44 +0000
Subject: [PATCH] Enterprise Cookbook preview: OpenHands/enterprise-cookbook#18
(do not merge)
Preview of the Enterprise Cookbook pages produced by https://github.com/OpenHands/enterprise-cookbook/pull/18 (Point docs links at docs.openhands.dev), rendered from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3.
**Do not merge.** This draft exists only to get a Mintlify preview. It is updated on every push to the source PR and closed when that PR closes. Merged changes reach the docs through a separate `cookbook-sync` PR.
_Opened automatically by the docs-preview workflow in OpenHands/enterprise-cookbook._
---
cookbook/archive-sandbox.mdx | 476 ++++++++++++++++++++++++
cookbook/clone-and-attach.mdx | 172 +++++++++
cookbook/command-blacklist.mdx | 22 +-
cookbook/conversation-tags.mdx | 8 +-
cookbook/gpg-commit-signing.mdx | 194 ++++++++++
cookbook/index.mdx | 42 ++-
cookbook/per-conversation-secrets.mdx | 448 ++++++++++++++++++++++
cookbook/service-account-github-pat.mdx | 283 ++++++++++++++
cookbook/start-sandbox.mdx | 136 +++++++
docs.json | 16 +
10 files changed, 1779 insertions(+), 18 deletions(-)
create mode 100644 cookbook/archive-sandbox.mdx
create mode 100644 cookbook/clone-and-attach.mdx
create mode 100644 cookbook/gpg-commit-signing.mdx
create mode 100644 cookbook/per-conversation-secrets.mdx
create mode 100644 cookbook/service-account-github-pat.mdx
create mode 100644 cookbook/start-sandbox.mdx
diff --git a/cookbook/archive-sandbox.mdx b/cookbook/archive-sandbox.mdx
new file mode 100644
index 000000000..bd10d639f
--- /dev/null
+++ b/cookbook/archive-sandbox.mdx
@@ -0,0 +1,476 @@
+---
+title: Archive Sandbox
+description: Delete conversations to release their Persistent Volume Claims (PVCs) and free storage resources.
+icon: box-archive
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (archive-sandbox/README.md). Edit the source, not this file. */}
+
+
+
+This example demonstrates how to properly archive/delete OpenHands conversations to release their Persistent Volume Claims (PVCs) and free up storage resources.
+
+When you create an OpenHands conversation, the system provisions a **sandbox** (also called a "runtime") that includes:
+
+- **Kubernetes Pod** - Container running the agent-server
+- **Kubernetes Service** - Network access to the pod
+- **Persistent Volume Claim (PVC)** - Storage for the workspace files
+- **Ingress/HTTPRoute** - External routing
+- **ServiceAccount, PodDisruptionBudget** - Supporting K8s resources
+
+The PVC persists even when a conversation is paused or stopped, allowing you to resume work later. However, PVCs consume storage quota and incur costs. To release these resources, you need to **delete the conversation**, which triggers full sandbox cleanup.
+
+## How It Works
+
+Based on the [runtime-api](https://github.com/All-Hands-AI/runtime-api) implementation, here's what happens when you delete a conversation:
+
+### 1. Conversation Deletion (`DELETE /api/v1/app-conversations/{conversation_id}`)
+
+The OpenHands Cloud API:
+
+- Marks the conversation as deleted in the database
+- Checks if any other conversations share the same sandbox
+- If no other conversations use the sandbox, triggers sandbox cleanup
+
+### 2. Sandbox Cleanup (Runtime API)
+
+The runtime API's `delete_runtime_and_workspace_in_k8s()` function removes all Kubernetes resources:
+
+```python
+# From runtime-api/k8s.py
+def delete_runtime_and_workspace_in_k8s(runtime_id, pod_id: str | None = None):
+ """Delete all K8s resources for a runtime."""
+
+ # Resources deleted in order:
+ # 1. Deployment
+ # 2. ServiceAccount
+ # 3. Service
+ # 4. PersistentVolumeClaim ← This releases the storage!
+ # 5. PodDisruptionBudget
+ # 6. HTTPRoute/Ingress
+ # 7. VolumeSnapshot (if any)
+```
+
+**Key Point**: The PVC deletion is what actually releases the storage. Once the PVC is deleted:
+
+- The underlying PersistentVolume (PV) is freed
+- Storage quota is released
+- Associated costs stop accumulating
+
+### 3. Resource Lifecycle States
+
+| State | Pod | PVC | Storage Released? |
+| ----------- | ------------- | ---------- | ----------------- |
+| **RUNNING** | ✅ Running | ✅ Exists | ❌ No |
+| **PAUSED** | ❌ Scaled to 0 | ✅ Exists | ❌ No |
+| **STOPPED** | ❌ Deleted | ✅ Exists\* | ❌ No |
+| **DELETED** | ❌ Deleted | ❌ Deleted | ✅ **Yes** |
+
+\* For standard (non-fuse) runtimes, the PVC persists when stopped to allow resuming. A VolumeSnapshot may be taken before deletion for archival purposes.
+
+## Prerequisites
+
+```bash
+# Set your OpenHands API key
+export OH_API_KEY="your-api-key-here"
+
+# Install dependencies
+pip install -r requirements.txt
+# or
+pip install requests
+```
+
+## Run It
+
+### Quick Start: Complete Workflow Demo
+
+Run the complete workflow example to see the entire process:
+
+```bash
+python example_create_and_archive.py
+```
+
+This will:
+
+1. Create a new test conversation (provisions sandbox + PVC)
+2. Show conversation details (sandbox_id, status, etc.)
+3. Ask for confirmation to archive
+4. Delete the conversation (releases PVC)
+5. Verify deletion
+
+Perfect for understanding the complete lifecycle!
+
+### Force Immediate PVC Cleanup
+
+If you need to release PVCs **immediately** without waiting for the cleanup cronjob, use the sandbox DELETE endpoint directly:
+
+```bash
+# List all sandboxes
+python force_cleanup.py --list
+
+# Force delete a specific sandbox (immediate PVC release)
+python force_cleanup.py
+
+# Force delete all idle (PAUSED/STOPPED) sandboxes
+python force_cleanup.py --cleanup-idle
+```
+
+**Key Difference**:
+
+- **Conversation DELETE** (`archive_sandbox.py`) - Deletes conversation, cleans up sandbox only if no other conversations use it
+- **Sandbox DELETE** (`force_cleanup.py`) - **Forces immediate cleanup** of all K8s resources including PVC, regardless of conversation state
+
+The sandbox DELETE endpoint directly calls the runtime-api's `stop_runtime()` function, which immediately executes `delete_runtime_and_workspace_in_k8s()`. This bypasses the cleanup cronjob and releases the PVC right away.
+
+**When to use force cleanup**:
+
+- ✅ You need immediate storage release
+- ✅ You're cleaning up test/development sandboxes
+- ✅ You've verified no important data remains
+- ⚠️ **Warning**: This deletes the sandbox even if conversations still reference it!
+
+### List All Conversations
+
+```bash
+python archive_sandbox.py list
+```
+
+Output shows conversation details including:
+
+- Conversation ID
+- Title
+- Sandbox ID (the runtime ID)
+- Status (RUNNING, STOPPED, etc.)
+- Cost metrics
+
+### Archive a Specific Conversation
+
+```bash
+python archive_sandbox.py archive
+```
+
+This will:
+
+1. Show conversation details
+2. Ask for confirmation
+3. Delete the conversation
+4. Trigger sandbox cleanup (if no other conversations use it)
+5. Release the PVC
+
+Example:
+
+```bash
+$ python archive_sandbox.py archive abc123def456
+
+Archiving conversation abc123def456...
+
+Conversation details:
+ ID: abc123def456
+ Title: My Test Conversation
+ Sandbox ID: m9dEVO2bDqar86rIaix93
+ Sandbox Status: STOPPED
+ Execution Status: stopped
+ Created: 2024-01-15T10:30:00Z
+ Updated: 2024-01-15T11:45:00Z
+ Cost: $0.4339
+
+This will delete the sandbox 'm9dEVO2bDqar86rIaix93' and release its PVC.
+
+Are you sure you want to delete this conversation? (yes/no): yes
+
+✓ Successfully deleted conversation abc123def456
+Response: {'success': True}
+
+The sandbox and its PVC have been cleaned up (if no other conversations use it).
+```
+
+### Bulk Cleanup Stopped Conversations
+
+```bash
+python archive_sandbox.py cleanup
+```
+
+This will:
+
+1. Find all stopped conversations
+2. Show a summary
+3. Ask for confirmation
+4. Archive all stopped conversations
+5. Release their PVCs
+
+This is useful for cleaning up after testing or development.
+
+## Important Concepts
+
+### Warm Runtimes and PVC Types
+
+OpenHands supports two types of sandboxes:
+
+#### Standard (PVC-backed)
+
+- PVC provisioned at startup
+- Workspace stored on persistent disk
+- PVC persists through pause/resume
+- **Must delete conversation to release PVC**
+
+#### Fuse/Dormant (S3-backed)
+
+- No PVC provisioned
+- Workspace stored in S3 via fusey
+- Mounted dynamically at claim time
+- Fast resume without PVC snapshots
+- **No PVC cleanup needed** - just deletes S3 objects
+
+### Cleanup Cronjob
+
+The runtime-api runs a cleanup cronjob (`cleanup.py`) every 5 minutes that:
+
+1. **Cleanup stuck PVCs** - Removes PVCs that never bound
+2. **Cleanup terminated pods** - Removes pods with no DB record
+3. **Pause idle runtimes** - Pauses runtimes idle > 30 min (configurable)
+4. **Snapshot and delete idle PVCs** - For paused standard runtimes
+5. **Delete old fuse workspaces** - Purges S3 objects for stopped fuse runtimes > 30 days
+6. **Delete old runtimes** - Removes K8s resources for runtimes stopped > 1 day
+
+**Important**: The cleanup cronjob does NOT delete PVCs for active or recently stopped conversations. You must explicitly delete the conversation to trigger immediate cleanup.
+
+## Files in This Example
+
+- **`archive_sandbox.py`** - Main CLI tool for archiving conversations
+- **`force_cleanup.py`** - Force immediate PVC cleanup by deleting sandbox directly
+- **`example_create_and_archive.py`** - Complete workflow demonstration
+- **`requirements.txt`** - Python dependencies
+- **`README.md`** - This documentation
+
+## Best Practices
+
+### When to Archive
+
+✅ **Do archive**:
+
+- Test conversations you no longer need
+- Failed or error conversations
+- Completed one-off tasks
+- Conversations stopped for > 7 days
+
+❌ **Don't archive**:
+
+- Active conversations (status: RUNNING)
+- Conversations you plan to resume soon
+- Conversations with important work not yet backed up
+
+### Before Archiving
+
+1. **Download important files** from the workspace
+2. **Export conversation history** if needed (use the download endpoint)
+3. **Check for workspace archives** - OpenHands may automatically archive workspaces before pause (see `tags.archiveworkspacepath`)
+
+### Workspace Archiving (Automatic)
+
+OpenHands can automatically archive workspace contents before pausing/stopping:
+
+```python
+# Environment variable controls this feature
+RUNTIME_FILE_ARCHIVE_ENABLED=true # Enable workspace archiving
+RUNTIME_FILE_ARCHIVE_FORMATS=tar.gz,git-delta # Archive formats
+RUNTIME_FILE_ARCHIVE_PREFIX=workspace-archives # S3 prefix
+```
+
+If enabled, the cleanup process will:
+
+1. Download workspace as tar.gz or git-delta
+2. Upload to S3 with manifest
+3. Tag conversation with archive path
+4. Then delete the PVC
+
+Check `conversation.tags.archiveworkspacepath` to see if an archive exists.
+
+## Monitoring and Verification
+
+### Check Kubernetes Resources
+
+If you have kubectl access to the runtime cluster:
+
+```bash
+# List all runtime pods
+kubectl get pods -n runtime-pods
+
+# List all PVCs
+kubectl get pvc -n runtime-pods
+
+# Check a specific runtime's resources
+kubectl get all,pvc -n runtime-pods -l runtime_id=
+```
+
+After deleting a conversation, these resources should be removed.
+
+### Check via API
+
+```bash
+# List your conversations
+curl -H "Authorization: Bearer $OH_API_KEY" \
+ "https://app.all-hands.dev/api/v1/app-conversations/search?limit=100" | jq
+
+# Get specific conversation (returns 404 if deleted)
+curl -H "Authorization: Bearer $OH_API_KEY" \
+ "https://app.all-hands.dev/api/v1/app-conversations/" | jq
+```
+
+## Example: Full Workflow
+
+Here's a complete example of creating and archiving a sandbox:
+
+```python
+import os
+import requests
+
+API_BASE = "https://app.all-hands.dev"
+API_KEY = os.getenv("OH_API_KEY")
+headers = {"Authorization": f"Bearer {API_KEY}"}
+
+# 1. Create a conversation (creates sandbox automatically)
+response = requests.post(
+ f"{API_BASE}/api/v1/app-conversations/stream-start",
+ headers=headers,
+ json={
+ "llm_model": "claude-sonnet-4-5-20250929",
+ "agent_kind": "openhands",
+ }
+)
+conv_data = response.json()
+conversation_id = conv_data["id"]
+sandbox_id = conv_data["sandbox_id"]
+
+print(f"Created conversation {conversation_id}")
+print(f"Sandbox ID: {sandbox_id}")
+
+# 2. Do some work...
+# (send messages, run commands, etc.)
+
+# 3. Archive when done
+response = requests.delete(
+ f"{API_BASE}/api/v1/app-conversations/{conversation_id}",
+ headers=headers
+)
+
+print(f"Archived conversation {conversation_id}")
+print(f"PVC for sandbox {sandbox_id} has been released")
+```
+
+## Troubleshooting
+
+### "Conversation not found" error
+
+The conversation may already be deleted. This is safe to ignore.
+
+### PVC still exists after deletion
+
+Possible reasons:
+
+1. **Another conversation uses the same sandbox** - The sandbox is shared
+2. **Cleanup not yet run** - K8s deletion is asynchronous, may take a few seconds
+3. **Kubernetes finalizers** - PV/PVC may have finalizers delaying deletion
+
+Check:
+
+```bash
+kubectl get pvc -n runtime-pods -o yaml | grep -A 5 finalizers
+```
+
+### Can't delete running conversation
+
+You can delete running conversations - the API will stop them first. However, it's cleaner to stop them explicitly:
+
+```bash
+# Stop first (optional)
+curl -X POST \
+ -H "Authorization: Bearer $OH_API_KEY" \
+ "https://app.all-hands.dev/api/organizations//conversations//stop"
+
+# Then delete
+python archive_sandbox.py archive
+```
+
+## Summary
+
+**Key Takeaways**:
+
+1. ✅ **Two ways to release PVCs**:
+ - **Conversation DELETE** - Deletes conversation, cleans up sandbox if not shared
+ - **Sandbox DELETE** - Force immediate cleanup, always releases PVC
+2. ✅ **No runtime-api access needed** - Use enterprise-server API with `OH_API_KEY`
+3. ✅ **Pausing/Stopping preserves the PVC** - For resuming work later
+4. ✅ **Force cleanup for immediate release** - Use `force_cleanup.py` to bypass cronjob
+5. ✅ **Bulk cleanup available** - Archive all stopped conversations/sandboxes at once
+6. ✅ **Automatic workspace archiving** - May preserve files before PVC deletion
+7. ✅ **Check before deleting** - Download important files first
+
+**Quick Decision Guide**:
+
+| Scenario | Use This | Command |
+| ---------------------------------- | ------------------------- | --------------------------------------------- |
+| Clean up one finished conversation | Conversation DELETE | `python archive_sandbox.py archive ` |
+| Need immediate storage release | Sandbox DELETE | `python force_cleanup.py ` |
+| Clean up all stopped conversations | Bulk conversation cleanup | `python archive_sandbox.py cleanup` |
+| Clean up all idle sandboxes | Bulk sandbox cleanup | `python force_cleanup.py --cleanup-idle` |
+
+When in doubt, remember: **DELETE sandbox → Immediate PVC release → Storage freed instantly** ✨
+
+## APIs Used
+
+### Enterprise-Server API (OpenHands Cloud)
+
+You can use these endpoints with just your `OH_API_KEY` - **no direct runtime-api access required**:
+
+| Endpoint | Method | Purpose | PVC Cleanup |
+| ------------------------------------------------------------------ | ------ | -------------------- | -------------------------------------------- |
+| `/api/v1/app-conversations/{conversation_id}` | DELETE | Delete conversation | ✅ Yes, if no other conversations use sandbox |
+| `/api/v1/sandboxes/{sandbox_id}` | DELETE | Force delete sandbox | ✅ **Immediate** - always deletes PVC |
+| `/api/v1/sandboxes/{sandbox_id}/pause` | POST | Pause sandbox | ❌ No - preserves PVC |
+| `/api/organizations/{org_id}/conversations/{conversation_id}/stop` | POST | Stop conversation | ❌ No - preserves PVC |
+
+**Key Insight**: Both conversation DELETE and sandbox DELETE work through the enterprise-server API. You do **NOT** need direct runtime-api access to force PVC cleanup!
+
+### How It Works
+
+```mermaid
+flowchart LR
+ User -->|OH_API_KEY| ES[Enterprise-Server API]
+ ES -->|internal| RA[Runtime-API]
+ RA -->|PVC deletion| K8s[Kubernetes]
+```
+
+When you call `DELETE /api/v1/sandboxes/{sandbox_id}`:
+
+1. Enterprise-Server validates your API key
+2. Calls runtime-api's `/stop` endpoint internally
+3. Runtime-api executes `delete_runtime_and_workspace_in_k8s()`
+4. PVC is immediately deleted from Kubernetes
+
+### Runtime-API Direct Access (Optional)
+
+If you have direct runtime-api access (e.g., via port-forward for testing), you can also use:
+
+| Endpoint | Method | Purpose |
+| -------- | ------ | ------------------------------------- |
+| `/stop` | POST | Stop runtime and delete all resources |
+| `/pause` | POST | Pause runtime (keeps PVC) |
+| `/list` | GET | List all runtimes |
+
+However, **in production you should use the enterprise-server API** (sandbox DELETE) which provides proper authentication, rate limiting, and audit logging.
+
+## Related
+
+
+
+ The service managing sandboxes
+
+
+
+ Full API reference
+
+
+
+ Persistent volume concepts
+
+
diff --git a/cookbook/clone-and-attach.mdx b/cookbook/clone-and-attach.mdx
new file mode 100644
index 000000000..876b75659
--- /dev/null
+++ b/cookbook/clone-and-attach.mdx
@@ -0,0 +1,172 @@
+---
+title: Clone and Attach
+description: Clone a repository and run its setup script in a sandbox, then attach a conversation to the prepared environment.
+icon: code-branch
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (clone-and-attach/README.md). Edit the source, not this file. */}
+
+
+
+This example provisions a sandbox **yourself** — shallow-cloning a git repo and
+running its setup script — and only *then* hands it to an OpenHands agent by
+**attaching a conversation** to that already-prepared sandbox.
+
+It builds directly on [`start-sandbox`](/cookbook/start-sandbox), which shows the bare
+sandbox lifecycle. Read that one first if the sandbox/agent-server split is new
+to you.
+
+## How It Works
+
+```mermaid
+sequenceDiagram
+ participant You
+ participant Cloud as Cloud app server
+ participant Agent as Sandbox agent server
+ You->>Cloud: POST /api/v1/sandboxes (1. start a sandbox, no conversation)
+ You->>Cloud: GET /api/v1/sandboxes?id=#lt;id#gt; (2. poll until status == RUNNING)
+ You->>Agent: POST /api/bash/execute_bash_command (3. git clone --depth 1 #lt;repo#gt;)
+ You->>Agent: POST /api/bash/execute_bash_command (4. bash .openhands/setup.sh)
+ You->>Cloud: POST /api/v1/app-conversations (5. attach a conversation, sandbox_id=#lt;id#gt;)
+ You->>Cloud: GET /api/v1/app-conversations/start-tasks (5b. poll for the app_conversation_id)
+```
+
+Steps 1–2 use the **Cloud app server** (auth header `X-Session-API-Key: `).
+Steps 3–4 use the sandbox's **agent server** (auth header
+`X-Session-API-Key: `, returned by the create call). Step 5 is
+back on the Cloud app server. See [`start-sandbox`](/cookbook/start-sandbox) for more
+on the two-server split.
+
+### Where does `setup.sh` live?
+
+In the repository, at `.openhands/setup.sh`. That is the exact location
+OpenHands itself runs every time it starts working with a repo — see
+[Repository Customization](/openhands/usage/customization/repository).
+This example runs that same file so the sandbox you hand off is set up the way
+the agent would expect. If a repo has no `.openhands/setup.sh`, the step is
+skipped with a note. (This repo ships a tiny one so the default run does
+something visible.)
+
+### Attaching is asynchronous
+
+`POST /api/v1/app-conversations` returns a **start task**, not the conversation
+itself. Poll `GET /api/v1/app-conversations/start-tasks?ids=` until it
+reports an `app_conversation_id`, then open
+`https://app.all-hands.dev/conversations/`.
+
+## Run It
+
+```bash
+export OH_API_KEY=... # your https://app.all-hands.dev API key
+pip install requests
+
+# Zero-config: clones this repo (it has a .openhands/setup.sh) and attaches
+# a conversation that summarizes it.
+python attach_conversation.py
+```
+
+Sample output:
+
+```text
+sandbox: 1ho9eZpt4m27CC23XPdGcN
+ sandbox status: RUNNING
+agent: https://ahhygodzefollslv.prod-runtime.all-hands.dev
+
+=== shallow clone https://github.com/OpenHands/enterprise-cookbook -> /workspace/enterprise-cookbook ===
+$ git clone (exit=0)
+
+=== run .openhands/setup.sh ===
+$ setup script (exit=0)
+[enterprise-cookbook setup.sh] running in /workspace/enterprise-cookbook
+[enterprise-cookbook setup.sh] python: Python 3.13.13
+[enterprise-cookbook setup.sh] done
+
+=== attach conversation ===
+ start-task status: STARTING_CONVERSATION
+ start-task status: READY
+
+Conversation attached to your prepared sandbox:
+ https://app.all-hands.dev/conversations/f041a2e252cf45b39a46a3189b2efce7
+```
+
+Open that URL and you'll find the agent already in a workspace where your repo
+is cloned and set up.
+
+## Why Would I Do This?
+
+Normally you start a conversation and OpenHands clones your selected repository
+for you. Sometimes you want more control *before* the agent gets involved:
+
+- pre-warm an environment so the agent starts instantly on an expensive setup,
+- check out a specific commit, tag, or a sub-path of a monorepo,
+- clone from a mirror or run custom bootstrapping the default flow doesn't do,
+- reuse one prepared sandbox for several scripted conversations.
+
+The trick is a single field: `POST /api/v1/app-conversations` accepts a
+`sandbox_id`. Pass the id of a sandbox you already prepared and the new
+conversation attaches to it instead of creating a fresh one.
+
+## Point It at Your Own Repo
+
+Every input is a flag with an environment-variable fallback, so the script is
+safe to drop into your own automation unchanged:
+
+| Flag | Env var | Default | Purpose |
+| ------------------- | ----------------- | --------------------------- | ----------------------------------------------- |
+| `--api-key` | `OH_API_KEY` | — (required) | Cloud API key |
+| `--base-url` | `OH_API_BASE` | `https://app.all-hands.dev` | Cloud app server |
+| `--repo` | `REPO_URL` | this repo | Git URL to shallow-clone |
+| `--branch` | `REPO_BRANCH` | repo default | Branch to check out |
+| `--depth` | `CLONE_DEPTH` | `1` | `git clone --depth` |
+| `--workdir` | `WORKDIR` | `/workspace` | Where the repo is cloned |
+| `--setup-script` | `SETUP_SCRIPT` | `.openhands/setup.sh` | Script to run after clone |
+| `--message` | `INITIAL_MESSAGE` | a summarize prompt | First message to the agent |
+| `--sandbox-id` | `SANDBOX_ID` | none | Reuse a RUNNING sandbox instead of creating one |
+| `--sandbox-spec-id` | `SANDBOX_SPEC_ID` | account default | Runtime image to start |
+| `--poll-timeout` | `POLL_TIMEOUT` | `240` | Seconds to wait for readiness |
+
+```bash
+python attach_conversation.py \
+ --repo https://github.com/your-org/your-repo \
+ --branch main \
+ --message "Run the test suite and fix any failures."
+```
+
+> Cloning a **private** repo? Start the sandbox with the appropriate git
+> credentials available (e.g. via sandbox secrets) or clone over an
+> authenticated URL. This example targets public repositories to stay simple.
+
+## Cleanup
+
+The sandbox is intentionally left running because a live conversation is now
+attached to it — deleting the sandbox ends that conversation. Delete it from the
+conversation UI, or via the API (the id goes in **both** the path and a required
+`sandbox_id` query parameter):
+
+```bash
+SID=
+curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/${SID}?sandbox_id=${SID}" \
+ -H "X-Session-API-Key: $OH_API_KEY"
+```
+
+## APIs Used
+
+| Endpoint | Method | Purpose |
+| --------------------------------------- | ------ | -------------------------------------------------------- |
+| `/api/v1/sandboxes` | POST | Start a sandbox (no conversation) |
+| `/api/v1/sandboxes` | GET | Poll until status == RUNNING |
+| `{agent}/api/bash/execute_bash_command` | POST | Run `git clone` and `.openhands/setup.sh` in the sandbox |
+| `/api/v1/app-conversations` | POST | Attach a conversation (`sandbox_id`) |
+| `/api/v1/app-conversations/start-tasks` | GET | Poll for the `app_conversation_id` |
+
+## Related
+
+
+
+ The bare sandbox lifecycle and the sandbox/agent-server split
+
+
+
+ Where .openhands/setup.sh lives
+
+
diff --git a/cookbook/command-blacklist.mdx b/cookbook/command-blacklist.mdx
index e68a1855f..a59b71cd8 100644
--- a/cookbook/command-blacklist.mdx
+++ b/cookbook/command-blacklist.mdx
@@ -1,12 +1,12 @@
---
-title: Command blacklist
+title: Command Blacklist
description: Block known-dangerous shell commands with a PreToolUse hook bundled in a plugin. Everything not on the blocklist runs normally.
icon: shield-halved
---
-{/* GENERATED from OpenHands/enterprise-cookbook@main (command-blacklist/README.md). Edit the source, not this file. */}
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (command-blacklist/README.md). Edit the source, not this file. */}
-
+
A self-contained example showing how to use **PreToolUse hooks** in a plugin to **blacklist dangerous shell commands**. When the agent tries to execute a risky command, the hook blocks it with helpful (and slightly snarky) feedback.
@@ -14,7 +14,7 @@ This example demonstrates the **blacklist approach**: block known dangerous patt
## What's in the Box
-The [`safety-guardian/`](https://github.com/OpenHands/enterprise-cookbook/tree/main/command-blacklist/safety-guardian) plugin bundles:
+The [`safety-guardian/`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-blacklist/safety-guardian) plugin bundles:
- **Hooks** (`hooks/hooks.json`) - PreToolUse hook that intercepts terminal commands
- **Skill** (`skills/safety-guardian/SKILL.md`) - Documentation about what's protected
@@ -56,11 +56,11 @@ All other commands work normally - only these specific dangerous patterns are bl
(So `rm -rf /tmp` is **not** blocked; use the `curl … | bash` demo below to see a block.)
-## Try It
+## Run It
- Use the companion [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/main/load-plugin) example:
+ Use the companion [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/load-plugin) example:
```bash
cd ../load-plugin
@@ -96,7 +96,7 @@ All other commands work normally - only these specific dangerous patterns are bl
## The Hook
-The magic happens in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/main/command-blacklist/safety-guardian/hooks/hooks.json):
+The magic happens in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/command-blacklist/safety-guardian/hooks/hooks.json):
```json safety-guardian/hooks/hooks.json
{
@@ -156,7 +156,7 @@ This example uses a **blacklist** approach:
- ❌ **Con:** Can't catch every dangerous pattern
- ❌ **Con:** Clever variations might slip through
-For high-security scenarios, see the companion [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/main/command-whitelist) example that shows the **whitelist** approach (only allow explicitly approved commands).
+For high-security scenarios, see the companion [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-whitelist) example that shows the **whitelist** approach (only allow explicitly approved commands).
## Hook Types
@@ -201,15 +201,15 @@ This follows the **Claude Code plugin format**, compatible with:
How plugins work
-
+
Programmatic plugin loading
-
+
No-code plugin launcher
-
+
Whitelist approach (opposite strategy)
diff --git a/cookbook/conversation-tags.mdx b/cookbook/conversation-tags.mdx
index c6868c431..70ab6932c 100644
--- a/cookbook/conversation-tags.mdx
+++ b/cookbook/conversation-tags.mdx
@@ -1,12 +1,12 @@
---
-title: Conversation tags
+title: Conversation Tags
description: Attach key-value metadata to a conversation with tags and read it back from AppConversation.tags.
icon: tags
---
-{/* GENERATED from OpenHands/enterprise-cookbook@main (conversation-tags/README.md). Edit the source, not this file. */}
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (conversation-tags/README.md). Edit the source, not this file. */}
-
+
Stash your own key-value metadata on an OpenHands conversation — for example an
external `environment_url` or `environment_conversation_id` — and read it back
@@ -49,7 +49,7 @@ and then *polls* the Cloud read instead of reading once.
> the agent server is the authoritative place to write them, and the Cloud
> reflects the result. The agent `POST /api/conversations` also accepts `tags`
> at creation time if you provision the sandbox yourself (see
-> [`clone-and-attach`](https://github.com/OpenHands/enterprise-cookbook/tree/main/clone-and-attach)).
+> [`clone-and-attach`](/cookbook/clone-and-attach)).
## Tag rules
diff --git a/cookbook/gpg-commit-signing.mdx b/cookbook/gpg-commit-signing.mdx
new file mode 100644
index 000000000..69ce380f4
--- /dev/null
+++ b/cookbook/gpg-commit-signing.mdx
@@ -0,0 +1,194 @@
+---
+title: GPG Commit Signing
+description: Configure GPG commit signing on every conversation with a SessionStart hook that imports a key from a custom secret.
+icon: file-signature
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (gpg-commit-signing/README.md). Edit the source, not this file. */}
+
+
+
+Configure **GPG commit signing at the start of every conversation** — not just
+when a repository is selected — using a `SessionStart` hook bundled in a plugin.
+
+The [`gpg-signer/`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/gpg-commit-signing/gpg-signer) plugin imports a private key from a
+[custom secret](#the-secret) and configures git to sign commits and tags
+**globally**, so signing applies to any repo the agent clones during the
+session.
+
+## The problem this solves
+
+Teams often configure signing today in `.openhands/setup.sh`:
+
+```bash
+#!/bin/bash
+set -euo pipefail
+echo "$gpg_key" | gpg --import
+KEY_ID=$(echo "$gpg_key" | gpg --show-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')
+git config user.signingkey "$KEY_ID"
+git config commit.gpgsign true
+git config user.name "$git_user_name"
+git config user.email "$git_user_email"
+```
+
+But **`setup.sh` only runs when the conversation is started with a repository
+selected.** Start a conversation with no repo (or have the agent clone a repo
+later) and signing is silently missing.
+
+A **`SessionStart` hook runs at the beginning of *every* conversation**,
+regardless of whether a repo was picked — exactly when you want signing set up.
+That is the whole idea behind this example.
+
+## Why the hook can read the same secret as `setup.sh`
+
+On the agent-server, hook scripts and `setup.sh` execute against the **same
+process environment**. A custom secret named `gpg_key` that `setup.sh` can read
+as `$gpg_key` is visible to the `SessionStart` hook the same way — so the logic
+you already run in `setup.sh` moves into the hook almost verbatim.
+
+Two differences from the raw `setup.sh` version, both deliberate:
+
+- **`git config --global`** instead of a bare `git config`. There is no
+ repository yet when `SessionStart` fires, and a global config applies to every
+ repo cloned afterwards.
+- **Never fails the session.** `SessionStart` hooks cannot block, so on any
+ problem (missing secret, bad key) the hook logs the reason to
+ `/tmp/openhands_gpg_setup.log` and exits `0`.
+
+## The secret
+
+Add these under **Settings → Secrets** in OpenHands (or pass them per
+conversation via the API):
+
+| Secret name | Contents | Required |
+| ---------------- | ---------------------------------------------------------------------------------- | -------- |
+| `gpg_key` | ASCII-armored **private** key — `gpg --armor --export-secret-keys you@example.com` | Yes |
+| `git_user_name` | Git author name | Optional |
+| `git_user_email` | Git author email (should match a key UID) | Optional |
+
+If `gpg_key` is absent the hook does nothing (and says so in the log), so it is
+safe to load the plugin for everyone and let it activate only where a key is
+configured.
+
+> Secret names are configurable through the `GPG_KEY_SECRET_NAME`,
+> `GIT_USER_NAME_SECRET_NAME`, and `GIT_USER_EMAIL_SECRET_NAME` environment
+> variables if your org uses different names.
+
+## What's in the box
+
+```text
+gpg-signer/
+├── .claude-plugin/
+│ └── plugin.json # Plugin metadata
+├── hooks/
+│ ├── hooks.json # SessionStart hook (inline script)
+│ └── scripts/
+│ └── configure_gpg_signing.sh # Readable reference copy of the inline script
+└── skills/
+ └── gpg-signer/
+ └── SKILL.md # Docs the agent reads (auto-loaded)
+```
+
+## The hook
+
+The logic lives in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/gpg-commit-signing/gpg-signer/hooks/hooks.json) under the
+`SessionStart` event:
+
+```json
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "*",
+ "hooks": [
+ { "type": "command", "command": "…import key + git config --global…", "timeout": 30 }
+ ]
+ }
+ ]
+ }
+}
+```
+
+**How it works:**
+
+1. **`SessionStart`** — runs once, when the conversation begins (no repo needed).
+2. Reads the armored key from `$gpg_key`, imports it with `gpg --batch --import`.
+3. Extracts the primary key fingerprint (`gpg --show-keys --with-colons`).
+4. Sets `user.signingkey`, `commit.gpgsign true`, `tag.gpgsign true`, and
+ `gpg.program` **globally**, plus the optional identity secrets.
+5. Exits `0` always; all human-readable output goes to
+ `/tmp/openhands_gpg_setup.log` (on a successful hook, stdout is parsed as
+ JSON, so it is kept clean).
+
+> **Why the script is inlined (and mirrored in `hooks/scripts/`).** When this
+> runs as a **plugin**, hooks execute with the working directory set to the
+> agent's workspace (not the plugin directory), and there is no plugin-root path
+> variable — so `command` **cannot** point at a bundled
+> `hooks/scripts/*.sh`. The runnable copy is therefore inlined in `hooks.json`;
+> [`hooks/scripts/configure_gpg_signing.sh`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/gpg-commit-signing/gpg-signer/hooks/scripts/configure_gpg_signing.sh)
+> is a readable reference that mirrors it. Edit the reference first, then mirror
+> it into `hooks.json`. (Same pattern as the
+> [`workspace-isolation`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/workspace-isolation) example.)
+
+## Try it
+
+### Option 1: Load via API
+
+Use the companion [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/load-plugin) example. Because the plugin
+needs your key, pass it as a per-conversation secret:
+
+```bash
+cd ../load-plugin
+python load_plugin.py \
+ --repo-path gpg-commit-signing/gpg-signer \
+ --secret gpg_key="$(gpg --armor --export-secret-keys you@example.com)" \
+ --secret git_user_name="Your Name" \
+ --secret git_user_email="you@example.com" \
+ --message "Show me the output of: git config --global --get commit.gpgsign and git config --global --get user.signingkey"
+
+# Expected: commit.gpgsign=true and user.signingkey=
+```
+
+> If you have already stored `gpg_key` as a user-level secret, the `--secret`
+> flags are unnecessary — the hook will pick it up automatically.
+
+### Option 2: Launch via badge
+
+Store `gpg_key` as a user secret first (the badge can't carry your private key),
+then click:
+
+[](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAiZ3BnLWNvbW1pdC1zaWduaW5nL2dwZy1zaWduZXIifV0%3D\&message=Show%20me%20the%20output%20of%3A%20git%20config%20--global%20--get%20commit.gpgsign%20and%20git%20config%20--global%20--get%20user.signingkey)
+
+> **Note:** Replace `ref: main` with your branch name if testing before merge.
+
+## Verifying a signed commit
+
+Once the hook has run, any commit is signed:
+
+```bash
+git clone https://github.com/octocat/Hello-World && cd Hello-World
+echo "test" >> README && git commit -am "signed test"
+git log --show-signature -1 # -> "Good signature from ..."
+```
+
+## Hook types
+
+`SessionStart` is one of several lifecycle events you can hook. See the other
+examples for blocking hooks:
+
+| Hook | When It Runs | Can Block? | Example |
+| ---------------- | ------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| PreToolUse | Before tool execution | ✅ Yes (exit 2) | [`command-blacklist`](/cookbook/command-blacklist), [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-whitelist), [`workspace-isolation`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/workspace-isolation) |
+| PostToolUse | After tool execution | ❌ No | Logging, metrics |
+| UserPromptSubmit | Before processing user message | ✅ Yes | Content filtering |
+| Stop | When agent tries to finish | ✅ Yes | Require artifacts |
+| **SessionStart** | **When conversation starts** | ❌ No | **Environment setup (this example)** |
+| SessionEnd | When conversation ends | ❌ No | Cleanup |
+
+## Related
+
+- [OpenHands Hooks Guide](/openhands/usage/customization/hooks) — repository `.openhands/hooks.json` format
+- [Hooks (SDK guide)](/sdk/guides/hooks) — programmatic hooks
+- [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/load-plugin) — programmatic plugin loading (and passing per-conversation secrets)
+- [`per-conversation-secrets`](/cookbook/per-conversation-secrets) — how secrets reach a conversation
+- [`command-blacklist`](/cookbook/command-blacklist) / [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-whitelist) / [`workspace-isolation`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/workspace-isolation) — other hook examples
diff --git a/cookbook/index.mdx b/cookbook/index.mdx
index 7a062399b..d4064359c 100644
--- a/cookbook/index.mdx
+++ b/cookbook/index.mdx
@@ -3,29 +3,65 @@ title: Enterprise Cookbook
description: Runnable examples for building on the OpenHands API with OpenHands Cloud or OpenHands Enterprise.
---
-{/* GENERATED from OpenHands/enterprise-cookbook@main (cookbook.yaml). Edit the source, not this file. */}
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (cookbook.yaml). Edit the source, not this file. */}
Standalone, runnable examples for teams building on the OpenHands API with OpenHands Cloud or
OpenHands Enterprise. Each page is generated from an example in
[OpenHands/enterprise-cookbook](https://github.com/OpenHands/enterprise-cookbook), where you
will find the full source.
+## Sandbox lifecycle
+
+Create, attach to, and tear down sandboxes.
+
+
+
+ Delete conversations to release their Persistent Volume Claims (PVCs) and free storage resources.
+
+
+
+ Clone a repository and run its setup script in a sandbox, then attach a conversation to the prepared environment.
+
+
+
+ Start a sandbox without a conversation and run commands via the agent-server REST API.
+
+
+
## Conversation monitoring & reacting
Observe conversations and react to their state.
-
+
Attach key-value metadata to a conversation with tags and read it back from AppConversation.tags.
+## Secrets & authentication
+
+Inject credentials and manage identity.
+
+
+
+ Configure GPG commit signing on every conversation with a SessionStart hook that imports a key from a custom secret.
+
+
+
+ Inject per-conversation secrets via REST API as bash env vars and to template an MCP server config bundled in a plugin.
+
+
+
+ Use one OpenHands account as a service account, overriding the managed GITHUB_TOKEN with each user's PAT per conversation.
+
+
+
## Guardrails
Constrain what the agent can do with hooks.
-
+
Block known-dangerous shell commands with a PreToolUse hook bundled in a plugin. Everything not on the blocklist runs normally.
diff --git a/cookbook/per-conversation-secrets.mdx b/cookbook/per-conversation-secrets.mdx
new file mode 100644
index 000000000..ec8a44338
--- /dev/null
+++ b/cookbook/per-conversation-secrets.mdx
@@ -0,0 +1,448 @@
+---
+title: Per-Conversation Secrets
+description: Inject per-conversation secrets via REST API as bash env vars and to template an MCP server config bundled in a plugin.
+icon: user-secret
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (per-conversation-secrets/README.md). Edit the source, not this file. */}
+
+
+
+This example demonstrates how to inject per-conversation secrets into an OpenHands
+conversation using only REST APIs (no WebSocket required), and — importantly — how
+those secrets can be **expanded into a plugin's MCP server configuration** so the
+agent can talk to a third-party MCP server with a token that lives only for the
+lifetime of one conversation.
+
+## Why you'd want this
+
+OpenHands already lets users define secrets at the *user* level (stored in the
+vault). That's fine for stable, long-lived credentials. But there are cases where
+you want a secret that is:
+
+- **Scoped to a single conversation** — e.g., a customer's OAuth token, a
+ temporary CI credential, or a one-off API key the calling system minted for
+ this run.
+- **Used to authenticate an MCP server**, not just exported as a bash variable —
+ e.g., wire the agent into a customer's Linear / GitHub / internal API by
+ passing the right `Authorization: Bearer …` header on every MCP call.
+
+This example shows both, and how they compose.
+
+## The two patterns shown here
+
+A single `secrets` field on the conversation-start request powers two related but
+distinct patterns. **Read this section before anything else** — most of the
+confusion in earlier versions of this README came from blurring them together.
+
+### Pattern A — Secrets as bash environment variables
+
+You pass `{"MY_KEY": "value"}` at conversation start (or inject it later via the
+agent server's `/secrets` endpoint), and `$MY_KEY` becomes available to every
+`bash` command the agent runs.
+
+- **Demonstrated by:** `test_secrets_at_start.py` (recommended) and
+ `test_secrets.py` (legacy / mid-conversation injection).
+- **Use when:** the agent needs a credential to run a CLI command, hit an HTTP
+ API via `curl`, or otherwise consume a secret from the shell.
+
+### Pattern B — Secrets as `${VARIABLE}` placeholders inside a plugin's MCP config
+
+The same `secrets` field also feeds variable expansion in an OpenHands
+**plugin**'s `.mcp.json`. A plugin is a small bundle of files (described below)
+that OpenHands fetches from GitHub at conversation start; if its `.mcp.json`
+contains `${MCP_SERVER_URL}` or `${MCP_SECRET_TOKEN}`, those placeholders are
+filled in from the conversation's secrets *before* the MCP transport is dialed.
+
+- **Demonstrated by:** `test_mcp_secrets_at_start.py` (recommended) and
+ `test_mcp_secrets.py` (legacy).
+- **Use when:** you want the agent to talk to an MCP server (yours or a
+ customer's) using a token that isn't in the user's vault — for example, a
+ per-tenant token chosen by your application for this conversation only.
+
+The rest of this document walks through Pattern B in detail because it's the
+less obvious of the two. Pattern A is just "set an env var" — see the test
+scripts for end-to-end examples.
+
+## What is a plugin (in this example)?
+
+A plugin is a directory in a git repo with three files. The whole `test-plugin/`
+folder in this repo is a working example:
+
+```text
+test-plugin/
+├── .mcp.json # MCP server registration WITH ${...} placeholders
+├── .plugin/plugin.json # manifest: name, version, author
+└── SKILL.md # human-readable doc the agent reads
+```
+
+**`test-plugin/.mcp.json`** — note the `${VARIABLE}` placeholders:
+
+```json
+{
+ "mcpServers": {
+ "token-validator": {
+ "url": "${MCP_SERVER_URL}/mcp",
+ "transport": "sse",
+ "headers": {
+ "Authorization": "Bearer ${MCP_SECRET_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+**`test-plugin/.plugin/plugin.json`** — minimal manifest:
+
+```json
+{
+ "name": "secret-token-validator",
+ "version": "1.0.0",
+ "description": "Plugin that connects to an MCP server using a per-conversation secret token",
+ "author": { "name": "OpenHands", "email": "openhands@all-hands.dev" },
+ "license": "MIT"
+}
+```
+
+**`test-plugin/SKILL.md`** — short markdown explaining the plugin's tools and
+required secrets. The agent reads this so it knows what the plugin exposes.
+
+You point a conversation at this plugin by adding a `plugins` entry alongside
+`secrets` in the start request:
+
+```python
+"plugins": [{
+ "source": "github:OpenHands/enterprise-cookbook",
+ "repo_path": "per-conversation-secrets/test-plugin"
+}]
+```
+
+`source` resolves to a GitHub repo, `repo_path` is the directory within it.
+OpenHands fetches the directory, reads `.mcp.json`, and **expands `${...}`
+references against the `secrets` you passed in the same request** before
+establishing the MCP transport. That expansion step is the whole point of
+Pattern B.
+
+## APIs used
+
+These tests exercise **two separate OpenHands APIs**:
+
+### 1. App Server API
+
+- **Purpose:** Manages sandboxes, conversations, and user resources.
+- **Base URL:** `https://app.all-hands.dev/api` (or your deployment).
+- **Auth header:** `X-Access-Token: `
+- **OpenAPI spec:** `https://app.all-hands.dev/openapi.json`
+
+### 2. Agent Server API
+
+- **Purpose:** Direct agent interaction inside a running sandbox.
+- **Base URL:** From the sandbox's `exposed_urls` array (entry with
+ `name="AGENT_SERVER"`).
+- **Auth header:** `X-Session-API-Key: ` (from sandbox creation).
+- **OpenAPI spec:** `{agent_server_url}/openapi.json`
+
+> **Tip:** To explore the Agent Server API, first create a sandbox via the App
+> Server, wait for it to reach `RUNNING` status, then fetch
+> `{agent_server_url}/openapi.json`.
+
+## Two ways to deliver the secrets
+
+Independent of A vs. B above, there are two *timings* for getting secrets into
+the conversation:
+
+### 1. At conversation start (recommended)
+
+Pass secrets directly in the `POST /v1/app-conversations` request body:
+
+```python
+requests.post(
+ f'{api_url}/v1/app-conversations',
+ headers={'X-Access-Token': api_key},
+ json={
+ 'sandbox_id': sandbox_id,
+ 'initial_message': {...},
+ 'secrets': {
+ 'GITHUB_TOKEN': 'ghp_...',
+ 'MCP_SECRET_TOKEN': 'per-conv-secret-xyz-123',
+ 'MCP_SERVER_URL': 'https://...',
+ },
+ 'plugins': [{ # only needed for Pattern B
+ 'source': 'github:OpenHands/enterprise-cookbook',
+ 'repo_path': 'per-conversation-secrets/test-plugin',
+ }],
+ },
+)
+```
+
+**Advantages:**
+
+- Single request — simpler API.
+- Secrets available immediately when the agent starts.
+- No race condition — guaranteed to be set before the agent runs.
+- Secrets are merged with vault secrets (request secrets take precedence).
+- **Required for Pattern B** — `${...}` expansion in a plugin's `.mcp.json`
+ needs the secrets to be present *before* the MCP transport is opened.
+
+**Requirements:**
+
+- OpenHands PR [#14009](https://github.com/OpenHands/OpenHands/pull/14009)
+- SDK PR [#2873](https://github.com/OpenHands/software-agent-sdk/pull/2873)
+
+### 2. After conversation start (original)
+
+Inject secrets via the Agent Server's `/secrets` endpoint after the conversation
+already exists:
+
+```python
+requests.post(
+ f'{agent_server_url}/api/conversations/{conv_id}/secrets',
+ headers={'X-Session-API-Key': session_api_key},
+ json={'secrets': {'MY_SECRET': 'value'}},
+)
+```
+
+**Use when:**
+
+- You need to add secrets mid-conversation.
+- You're on an older OpenHands version without the at-start `secrets` field.
+
+Note: post-hoc injection works for Pattern A (bash env vars) but **does not help
+with Pattern B**, because the MCP transport for a plugin is established when the
+conversation starts.
+
+## Quick comparison
+
+| Feature | At start (new) | After start (original) |
+| ------------------------ | ---------------------------- | -------------------------------------- |
+| API | App Server | Agent Server |
+| Endpoint | `POST /v1/app-conversations` | `POST /api/conversations/{id}/secrets` |
+| Auth header | `X-Access-Token: {api_key}` | `X-Session-API-Key: {session_key}` |
+| Timing | Before agent runs | After conversation created |
+| Simplicity | Single request | Multiple requests |
+| Mid-conversation updates | No | Yes |
+| Works for Pattern A | Yes | Yes |
+| Works for Pattern B | **Yes** | No |
+
+## Architecture
+
+```text
+┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
+│ App Server │ │ Agent Server │ │ MCP Server │
+│ app.all-hands.dev │ │ (per-sandbox URL) │ │ (validates token) │
+├─────────────────────┤ ├──────────────────────┤ ├─────────────────────┤
+│ POST /v1/sandboxes │──▶│ │ │ │
+│ POST /v1/app-conv │ │ │ │ │
+│ • secrets │ │ Plugin loader │ │ │
+│ • plugins ────────┼──▶│ ┌────────────────┐ │ │ │
+│ │ │ │ fetch from GH │ │ │ │
+│ │ │ │ read .mcp.json │ │ │ │
+│ │ │ │ expand ${...} │ │ │ │
+│ │ │ │ ↑ from │ │ │ │
+│ │ │ │ secrets │ │ │ │
+│ │ │ └───────┬────────┘ │ │ │
+│ │ │ │ │ │ │
+│ │ │ ▼ │ │ │
+│ │ │ MCP transport ──────┼──▶│ validate_token() │
+│ │ │ (Authorization: │ │ │
+│ │ │ Bearer ) │ │ │
+│ │ │ │ │ │
+│ │ │ POST /secrets ──── (Pattern A, after-start) │
+│ │ │ POST /events │ │ │
+└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
+ │ │ │
+ │ X-Access-Token: │ X-Session-API-Key: │ Authorization:
+ │ {api_key} │ {session_api_key} │ Bearer ${MCP_SECRET_TOKEN}
+```
+
+## Key findings
+
+1. **Two different conversation IDs.** The App Server and the Agent Server use
+ different IDs for the same conversation. You must query the Agent Server to
+ find the correct ID for its endpoints.
+2. **Two authentication schemes.**
+ - App Server: `X-Access-Token: {api_key}`
+ - Agent Server: `X-Session-API-Key: {session_api_key}`
+3. **Secrets endpoint (Agent Server):** `POST /api/conversations/{id}/secrets`
+ with body `{"secrets": {"KEY": "value"}}`. Secrets become environment
+ variables (`$KEY`) for bash commands.
+4. **`${...}` expansion only works when secrets are passed at start.** A plugin
+ loaded via `plugins: [...]` has its `.mcp.json` expanded against the
+ `secrets` field of the same `POST /v1/app-conversations` request. Post-hoc
+ injection via the Agent Server's `/secrets` endpoint is too late to influence
+ MCP transport setup.
+
+## Files
+
+| File | Purpose |
+| --------------------------------- | ---------------------------------------------------------------------------------------- |
+| `test_secrets_at_start.py` | **Pattern A**, at-start: secrets as bash env vars, passed in the start request. |
+| `test_secrets.py` | **Pattern A**, after-start: secrets injected via Agent Server `/secrets`. |
+| `test_mcp_secrets_at_start.py` | **Pattern B**, at-start: secrets + plugin in one request, MCP config expanded from them. |
+| `test_mcp_secrets.py` | **Pattern B**, after-start: legacy variant. Kept for reference. |
+| `test-plugin/.mcp.json` | Plugin's MCP server registration with `${MCP_SERVER_URL}` / `${MCP_SECRET_TOKEN}`. |
+| `test-plugin/.plugin/plugin.json` | Plugin manifest (name, version, author, license). |
+| `test-plugin/SKILL.md` | Human-readable doc the agent reads to understand the plugin. |
+| `mcp_server.py` | Stand-alone MCP server that validates the expected `Authorization: Bearer …` token. |
+
+## Usage
+
+### Pattern A — secrets at conversation start (recommended)
+
+```bash
+export OH_API_KEY="sk-oh-..."
+
+# Optional: staging / feature deployment
+# export OH_API_URL="https://ohpr-14009-30.staging.all-hands.dev/api"
+
+# Optional: reuse an existing RUNNING sandbox (avoids cold start)
+# export OH_SANDBOX_ID="your-sandbox-id"
+
+python test_secrets_at_start.py
+```
+
+Expected output ends with:
+
+```text
+======================================================================
+ ✅ SUCCESS! Secrets field was accepted in AppConversationStartRequest!
+======================================================================
+```
+
+### Pattern A — secrets after conversation start (legacy)
+
+```bash
+export OH_API_KEY="sk-oh-..."
+python test_secrets.py
+```
+
+### Pattern B — secrets + plugin at conversation start (recommended)
+
+This test verifies that secrets passed at conversation start are available for
+MCP config variable expansion inside the plugin's `.mcp.json`.
+
+```bash
+# Terminal 1: start the MCP server
+python mcp_server.py --port 12000 --expected-token "per-conv-secret-xyz-123"
+
+# Terminal 2: run the test
+export OH_API_KEY="sk-oh-..."
+export OH_API_URL="https://ohpr-14009-30.staging.all-hands.dev/api" # or your deployment
+export MCP_SERVER_URL="https://work-1-xxx.prod-runtime.all-hands.dev" # where mcp_server.py is reachable
+
+python test_mcp_secrets_at_start.py
+```
+
+Expected output ends with:
+
+```text
+======================================================================
+ ✅ SUCCESS! Per-conversation secret was injected and used!
+======================================================================
+```
+
+What happened end-to-end:
+
+1. The test started a sandbox and called `POST /v1/app-conversations` with
+ `secrets={"MCP_SERVER_URL": ..., "MCP_SECRET_TOKEN": "per-conv-secret-xyz-123"}`
+ *and* `plugins=[{source: github:OpenHands/enterprise-cookbook, repo_path: per-conversation-secrets/test-plugin}]`.
+2. OpenHands fetched `test-plugin/` from GitHub, read `.mcp.json`, and
+ substituted both `${MCP_SERVER_URL}` and `${MCP_SECRET_TOKEN}` from the
+ secrets above.
+3. The agent dialed the resulting URL with header
+ `Authorization: Bearer per-conv-secret-xyz-123`.
+4. `mcp_server.py` compared the token to its `--expected-token`, matched, and
+ returned a success result that the test then asserts on.
+
+## API workflow
+
+```python
+# ============================================================
+# APP SERVER API (https://app.all-hands.dev/api)
+# Auth: X-Access-Token header
+# ============================================================
+
+# 1. Create sandbox
+POST /v1/sandboxes
+Headers: X-Access-Token: {api_key}
+→ {id, session_api_key, status: "STARTING", exposed_urls: null}
+
+# 2. Poll for RUNNING status
+GET /v1/sandboxes/search
+Headers: X-Access-Token: {api_key}
+→ {items: [{id, status: "RUNNING", exposed_urls: [...], session_api_key}]}
+# Find AGENT_SERVER in exposed_urls
+
+# 3. Start conversation WITH secrets (and optionally a plugin)
+POST /v1/app-conversations
+Headers: X-Access-Token: {api_key}
+Body: {
+ sandbox_id: "...",
+ initial_message: {...},
+ secrets: {...},
+ plugins: [{source: "github:...", repo_path: "..."}] # for Pattern B
+}
+
+# ============================================================
+# AGENT SERVER API (from exposed_urls AGENT_SERVER)
+# Auth: X-Session-API-Key header
+# ============================================================
+
+# 4. Find conversation on agent server
+GET /api/conversations/search
+Headers: X-Session-API-Key: {session_api_key}
+→ {items: [{id, status}]}
+
+# 5. Send message / check events
+POST /api/conversations/{id}/events
+GET /api/conversations/{id}/events/search
+Headers: X-Session-API-Key: {session_api_key}
+```
+
+## API reference
+
+### App Server API
+
+| Endpoint | Method | Description |
+| ----------------------- | ------ | ------------------------------------------------------------ |
+| `/v1/sandboxes` | POST | Create sandbox → `{id, session_api_key, ...}` |
+| `/v1/sandboxes/search` | GET | List sandboxes → `{items: [...]}` |
+| `/v1/sandboxes/{id}` | DELETE | Delete sandbox (use `?sandbox_id=` query param) |
+| `/v1/app-conversations` | POST | Start conversation (supports `secrets` and `plugins` fields) |
+
+### Agent Server API
+
+| Endpoint | Method | Description |
+| --------------------------------------- | ------ | --------------------------------------- |
+| `/api/conversations/search` | GET | List conversations → `{items: [...]}` |
+| `/api/conversations/{id}/secrets` | POST | Inject secrets (Pattern A, after start) |
+| `/api/conversations/{id}/events` | POST | Send user message |
+| `/api/conversations/{id}/events/search` | GET | List events → `{items: [...]}` |
+
+### Getting the OpenAPI specs
+
+```bash
+# App Server OpenAPI
+curl https://app.all-hands.dev/openapi.json
+
+# Agent Server OpenAPI (requires a running sandbox)
+# 1. Create sandbox and wait for RUNNING status
+# 2. Get agent_server_url from exposed_urls (name="AGENT_SERVER")
+curl {agent_server_url}/openapi.json
+```
+
+## Related
+
+
+
+ Use OpenHands as a service account with per-user PATs
+
+
+
+ Configure GPG signing with SessionStart hooks
+
+
+
+ Full API documentation
+
+
diff --git a/cookbook/service-account-github-pat.mdx b/cookbook/service-account-github-pat.mdx
new file mode 100644
index 000000000..d4a0b94dd
--- /dev/null
+++ b/cookbook/service-account-github-pat.mdx
@@ -0,0 +1,283 @@
+---
+title: Service Account GitHub PAT
+description: Use one OpenHands account as a service account, overriding the managed GITHUB_TOKEN with each user's PAT per conversation.
+icon: id-card
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (service-account-github-pat/README.md). Edit the source, not this file. */}
+
+
+
+**Question:** *Can I use one OpenHands SaaS account as a service account that performs
+GitHub operations on behalf of many different users, by supplying each user's GitHub
+Personal Access Token (PAT) as a per-conversation secret?*
+
+**Short answer:** **Yes.** OpenHands lets a conversation-start request override the
+account's own managed `GITHUB_TOKEN` with a token you supply. So a single "backend"
+account can front many users by passing the right user's PAT for each conversation.
+
+But "supply a PAT and you're done" hides three things that will bite you if you skip
+them:
+
+1. **Where the token is (and isn't) readable** — the sandbox has two command paths and
+ only one of them sees the secret.
+2. **The identity split** — the *push* credential and the *commit author* are two
+ different identities, and by default only the push side becomes the PAT owner.
+3. **The clone trap** — if you attach a repository at conversation start, it is cloned
+ with the *service account's* credentials, not the PAT.
+
+This guide explains the mechanics and the techniques to get it right.
+
+***
+
+## 1. The core mechanism: `secrets` overrides the managed token
+
+When you start a conversation you can pass a `secrets` map. `GITHUB_TOKEN` is explicitly
+on the list of **overridable** system secrets, so a value you pass wins over the
+account's own managed GitHub token:
+
+```jsonc
+POST /api/v1/app-conversations
+{
+ "sandbox_id": "…",
+ "initial_message": { "role": "user", "content": [{ "type": "text", "text": "…" }] },
+ "secrets": {
+ "GITHUB_TOKEN": "ghp_thePatOfTheUserYouAreActingFor"
+ }
+}
+```
+
+What happens server-side:
+
+- The app-server first builds the secret set from the account's own identity: the
+ managed `GITHUB_TOKEN` is a **refreshing lookup** (a `LookupSecret` that calls back to
+ the app-server's `/api/v1/webhooks/secrets` to fetch a freshly-minted token each time
+ it's read).
+- Your API-provided secrets are then **merged last, last-write-wins**. `GITHUB_TOKEN`
+ becomes a **static** value (your PAT), replacing the refreshing lookup. The server logs
+ `API-provided secret 'GITHUB_TOKEN' overrides existing secret`.
+
+Two consequences worth internalizing:
+
+- **The auto-refresh no longer applies to `GITHUB_TOKEN`.** That's fine for a PAT (PATs
+ don't need refreshing), but it means the token you inject is used as-is for the life of
+ the conversation. If it expires or is revoked mid-run, there is no refresh path.
+- **There is no ownership check.** The platform does not verify that the PAT belongs to
+ the calling account or to any particular user. Whoever holds the service account's API
+ key can make the agent act with *any* PAT. Treat that API key accordingly (see
+ [§7 Security](#7-security-and-operational-considerations)).
+
+### What you may and may not override
+
+- `GITHUB_TOKEN`, `GITLAB_TOKEN`, `BITBUCKET_TOKEN`, `AZURE_DEVOPS_TOKEN`,
+ `FORGEJO_TOKEN`, and the AWS Bedrock credentials are **overridable** by design — this
+ BYO-credential flow is exactly what they're for.
+- Names starting with `LLM_` are **blocked** (they enforce LLM controls).
+- A set of container/infra names (`OH_WEBHOOKS_0_BASE_URL`, `OH_ALLOW_CORS_ORIGINS_0`,
+ worker ports, etc.) are **blocked** so you can't repoint callbacks or break the sandbox.
+- Limits: a bounded number of secrets per request, bounded name length, bounded value
+ size. Names are validated.
+
+***
+
+## 2. The two execution paths (this is the crux)
+
+Inside the sandbox there are **two different ways** a shell command can run, and they do
+**not** have the same access to your secret:
+
+| | Agent tool-call path | Direct exec path |
+| -------------------------- | ------------------------------------------------------ | -------------------------------------- |
+| Who triggers it | the agent (LLM) running its `bash` tool | you, calling the agent-server REST API |
+| Endpoint | conversation `events` → tool executor | `POST /api/bash/execute_bash_command` |
+| Sees conversation secrets? | **Yes, but only if the command text names the secret** | **No — never** |
+
+Why:
+
+- Conversation secrets live in the SDK's **secret registry**. The registry is consulted
+ **only** by the agent's terminal tool, and it works by **scanning the command text** for
+ registered secret names. If the literal name (e.g. `GITHUB_TOKEN`) appears in the
+ command, its value is exported as an env var for that command; otherwise nothing is
+ injected.
+- The direct `POST /api/bash/...` endpoint is a standalone bash service with no
+ conversation context and no secret registry. It runs the raw command. It cannot read
+ registry secrets no matter what the command says.
+
+Practical fallout:
+
+- `git remote set-url origin https://${GITHUB_TOKEN}@github.com/owner/repo.git` **works**
+ on the agent path — the literal `GITHUB_TOKEN` is present, so it's injected.
+- `gh pr create …` on the agent path **does not** get the token — the string
+ `GITHUB_TOKEN` doesn't appear, so `gh` runs unauthenticated. Force it:
+ `GITHUB_TOKEN=$GITHUB_TOKEN gh pr create …`.
+- Nested scripts, background processes, and git credential helpers only see the token if
+ the **top-level** command named it (env is then inherited by children). Once a command
+ in the agent's shell session references the secret, the `export` persists for the rest
+ of that session.
+
+> Rule of thumb: **on the agent path, name the secret in the command.** On the direct
+> path, the registry secret isn't available at all — if you need a credential there, you
+> must place it yourself (you hold the plaintext).
+
+***
+
+## 3. The identity split: push credential ≠ commit author
+
+This is the detail most people miss.
+
+- **Push credential** = the token used for `git push`. Override `GITHUB_TOKEN` and this
+ becomes the **PAT owner**. ✅
+- **Commit author/committer** = the `user.name` / `user.email` baked into the commit at
+ `git commit` time. GitHub attributes a commit to an account by matching the **author
+ email**. The token has *no effect* on this.
+
+By default the app-server configures git identity **from the service account's** stored
+`git_user_name` / `git_user_email` (as `git config --global`). So out of the box you get:
+
+- commits **authored by the service account** (or, if the account has no git identity
+ set, a container fallback like `root@…`), and
+- pushed **as the PAT owner**.
+
+That split pollutes contribution graphs, "Verified"/linked authorship, and your audit
+trail. **If attribution matters, set the committer identity to the target user yourself.**
+
+- Your system should **track each user's git identity** — specifically the GitHub-linked
+ email, typically the noreply form `ID+login@users.noreply.github.com` — and set it in
+ the sandbox:
+ ```bash
+ git config user.name "Jane Example"
+ git config user.email "12345+jane@users.noreply.github.com"
+ ```
+ These are **non-secret strings**, so they work over either execution path. Repo-local
+ config overrides the account's `--global` default.
+- Don't try to discover the identity from the token unless you must — `gh api user` needs
+ the token (so it must be on the agent path *and* name `GITHUB_TOKEN`), adds a round
+ trip, and adds a failure mode. Tracking it yourself is more robust.
+
+***
+
+## 4. The clone trap: don't attach a repository at start
+
+When you pass `selected_repository` at conversation start, the app-server clones it
+**before** your PAT is in play, using the **service account's** credentials — the
+service-account token is embedded directly into the `origin` remote URL. You'd then have
+a repo cloned and remote-authenticated as the service account while the agent thinks it's
+the PAT user. Mixed identity, again.
+
+**Technique: start with no repository attached, and clone inside the session with the
+PAT.** With no repo selected, the app-server skips the authenticated clone entirely, and
+the agent's `GITHUB_TOKEN` is uniformly the injected PAT.
+
+The clone must be **agent-driven** (or done by you over the direct path with the plaintext
+token), because in the plain app-conversation flow the PAT only reaches the sandbox once
+the conversation starts — after the app-server's own setup step has already run. See the
+sandbox-first flow below if you want to control that ordering.
+
+***
+
+## 5. Two implementation flows
+
+### Flow A — Simple: app-conversation with `secrets` (no repo attached)
+
+Best when you're happy to let the agent do the cloning and you don't need setup to read
+the token.
+
+1. `POST /api/v1/app-conversations` with `secrets: { GITHUB_TOKEN: }`, **no
+ `selected_repository`**, and an initial message that tells the agent to:
+ - set git identity to the target user (pass the name/email in the message or a skill),
+ - clone with the PAT, e.g.
+ `git clone https://x-access-token:${GITHUB_TOKEN}@github.com/owner/repo.git`,
+ - reference `GITHUB_TOKEN` explicitly in any `gh`/push commands.
+
+This is a superset of the [`per-conversation-secrets`](/cookbook/per-conversation-secrets)
+example — same `secrets` field, just used for `GITHUB_TOKEN`.
+
+### Flow B — Sandbox-first: full control over ordering
+
+Best when you want the credential and identity **in place before the agent runs**, or you
+want to pre-clone / run setup as the correct identity.
+
+1. `POST /api/v1/sandboxes` yourself; poll to `RUNNING`; read `session_api_key` and the
+ `AGENT_SERVER` URL from `exposed_urls`.
+2. Drive the sandbox directly over `POST /api/bash/execute_bash_command` (you hold the
+ plaintext PAT, so you're not dependent on the registry here):
+ - `git config --global user.name/user.email` → the **target user's** identity,
+ - clone the repo with the PAT, or place a credential helper / env entry,
+ - run any setup you need.
+3. Attach a conversation to the prepared sandbox: `POST /api/v1/app-conversations` with
+ that `sandbox_id`. **Also** register the PAT in the conversation `secrets` so the
+ agent's own tool-path commands can use `$GITHUB_TOKEN` by name.
+
+This builds on the [`start-sandbox`](/cookbook/start-sandbox) example (create sandbox → talk to
+the agent-server directly), then attaches a conversation on top.
+
+> Important nuance: injecting secrets "at start" or via the agent-server's
+> `POST /api/conversations/{id}/secrets` endpoint both land in the **conversation
+> registry** — i.e. agent-tool-path only, name-scanned. Creating the sandbox earlier does
+> **not** make those registry secrets readable by `POST /api/bash/...`. What the
+> sandbox-first flow buys you is *ordering* plus the fact that *you already hold the
+> plaintext* and can place it however you like.
+
+***
+
+## 6. What still resolves to the service account (residual coupling)
+
+Even with no repo attached and the PAT injected, a few things are still the **service
+account**, because they run at conversation start regardless:
+
+- **Skill loading.** The app-server enumerates the *service account's* login and orgs and
+ fetches their `.openhands` / `.agents` skill repos using the service account's token.
+ Those authenticated URLs (with the service-account token) are handed to the sandbox. If
+ you want zero service-account credential exposure in the sandbox, consider disabling
+ org/user skill loading for this flow, and keep the service account's scopes minimal.
+- **Other providers.** Only `GITHUB_TOKEN` is overridden. Any `GITLAB_TOKEN`,
+ `BITBUCKET_TOKEN`, etc. still refresh against the service account.
+- **Server-side attribution.** Conversation ownership, telemetry, and webhook identity are
+ the service account. GitHub-side actions are attributed to the PAT owner. Your audit
+ trail is inherently split across the two systems — plan for correlating them.
+
+***
+
+## 7. Security and operational considerations
+
+- **The service account API key is now an "act as anyone" key.** There's no server-side
+ check tying an injected PAT to a user. Guard the API key like the high-value credential
+ it is; do your own authorization *before* choosing which PAT to inject.
+- **Scope the PATs.** Prefer fine-grained PATs limited to the repos/permissions each
+ operation needs. The injected token becomes readable inside the sandbox (agent path)
+ and lives in conversation state for the conversation's lifetime.
+- **Blast radius of the sandbox.** Anyone with the sandbox's `session_api_key` can drive
+ `POST /api/bash/...`. In the sandbox-first flow you place plaintext tokens there
+ yourself — treat the sandbox as sensitive and tear it down when done.
+- **Output masking.** Secret values registered in the conversation are masked as
+ `` in tool output, but only for values the registry knows and has
+ resolved. Tokens you place yourself over the direct path are **not** masked — avoid
+ echoing them.
+- **Prefer short-lived tokens where possible.** Because the override disables the managed
+ refresh, a long conversation with an expiring token has no recovery path; mint fresh or
+ keep conversations bounded.
+
+***
+
+## 8. Implementation checklist
+
+- [ ] Authorize the request in **your** system and pick the correct user's PAT.
+- [ ] Start the conversation **without** `selected_repository`.
+- [ ] Pass the user's PAT as `secrets.GITHUB_TOKEN`.
+- [ ] Set the **committer identity** to the target user (`git config user.name/email`,
+ using a GitHub-linked email you track). Do it before any commit.
+- [ ] In agent commands, **name `GITHUB_TOKEN`** wherever the token is needed
+ (`GITHUB_TOKEN=$GITHUB_TOKEN gh …`, or embed it in the clone/remote URL).
+- [ ] Decide clone strategy: agent-driven (Flow A) or pre-clone over the direct path
+ (Flow B).
+- [ ] Minimize service-account scopes; consider disabling org/user skill loading.
+- [ ] Have a correlation strategy for the split audit trail.
+- [ ] Use fine-grained, minimally-scoped, ideally short-lived PATs; tear down sandboxes.
+
+***
+
+## Related
+
+- [`per-conversation-secrets`](/cookbook/per-conversation-secrets) — the `secrets` field at
+ start vs. after start, and MCP `${VAR}` expansion.
+- [`start-sandbox`](/cookbook/start-sandbox) — create a sandbox and drive its agent-server REST
+ API directly (the basis for Flow B).
diff --git a/cookbook/start-sandbox.mdx b/cookbook/start-sandbox.mdx
new file mode 100644
index 000000000..71ab963d7
--- /dev/null
+++ b/cookbook/start-sandbox.mdx
@@ -0,0 +1,136 @@
+---
+title: Start Sandbox
+description: Start a sandbox without a conversation and run commands via the agent-server REST API.
+icon: play
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (start-sandbox/README.md). Edit the source, not this file. */}
+
+
+
+A short script that creates an OpenHands Cloud sandbox via the V1 API,
+waits for it to reach `RUNNING`, then talks directly to the sandbox's
+agent-server REST API to run shell commands.
+
+No conversation is created — useful when you want a managed remote workspace
+to drive yourself (e.g. for tooling, batch jobs, or programmatic agents).
+
+Two versions are provided:
+
+- [`sandbox_demo_brief.py`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/start-sandbox/sandbox_demo_brief.py) — \~25 lines,
+ one-numbered-step-per-block, minimal error handling. Best for quickly
+ understanding the API shape.
+- [`sandbox_demo.py`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/start-sandbox/sandbox_demo.py) — same flow but split into a
+ `main()` function with docstrings and type hints, more in line with
+ the rest of the repo.
+
+Both do the same thing and both intentionally leave the sandbox running
+at the end.
+
+> Want to go further? [`clone-and-attach`](/cookbook/clone-and-attach) builds on this
+> example: it clones a repo and runs its `.openhands/setup.sh` in the sandbox,
+> then **attaches a conversation** to the prepared sandbox.
+
+## APIs Used
+
+### 1. Cloud App Server — manages the sandbox lifecycle
+
+- Base URL: `https://app.all-hands.dev`
+- Auth header: `X-Session-API-Key: `
+- Endpoints:
+ - `POST /api/v1/sandboxes` — start a sandbox (optional `?sandbox_spec_id=…`)
+ - `GET /api/v1/sandboxes?id=` — batch-get sandboxes by id
+ (returns `SandboxInfo` with `status`, `session_api_key`, `exposed_urls`, …)
+
+### 2. Agent Server — runs inside the sandbox
+
+- Base URL: the entry in `sandbox.exposed_urls` with `name == "AGENT_SERVER"`.
+ Inside the container the agent server listens on port **60000** (you'll see
+ that in the sample `ps -ef` output below and in the `port` field of the
+ `exposed_urls` entry); it is reverse-proxied to the public HTTPS URL, so you
+ always talk to it over `https://…` (port 443) — never the internal port.
+- Auth header: `X-Session-API-Key: ` returned by the
+ sandbox-create call (different from your Cloud API key)
+- All routes are under `/api/...`. This example uses:
+ - `POST /api/bash/execute_bash_command` — run a bash command and wait
+ for its result; body `{ "command": "...", "timeout": 30, "cwd": "..." }`,
+ response includes `stdout`, `stderr`, `exit_code`
+
+> Full Agent Server schema is available at
+> `/openapi.json` once the sandbox is `RUNNING`.
+
+## What it prints
+
+```text
+sandbox: 59Ji2kvkUtZZSm7zAkAxwN
+ status: RUNNING
+agent: https://rzwfxneubhwcfpav.prod-runtime.all-hands.dev
+
+=== ls -la /workspace ===
+drwxr-sr-x bash_events
+drwxr-sr-x conversations
+drwxrws--- lost+found
+
+=== agent-server process ===
+openhan+ 1 /usr/local/bin/openhands-agent-server --port 60000
+openhan+ 38 /usr/local/bin/openhands-agent-server --port 60000
+```
+
+`/workspace` is the sandbox's working tree. The agent-server is the binary
+target built from the
+[software-agent-sdk Dockerfile](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-agent-server/openhands/agent_server/docker/Dockerfile).
+You'll see two processes: a uvicorn parent and a worker.
+
+## Run it
+
+```bash
+export OH_API_KEY=... # your https://app.all-hands.dev API key
+pip install requests
+python sandbox_demo_brief.py # or sandbox_demo.py
+```
+
+`sandbox_demo.py` also takes flags / env vars so you can reuse it as-is:
+
+| Flag | Env var | Default |
+| ------------------- | ----------------- | ---------------------------- |
+| `--api-key` | `OH_API_KEY` | — (required) |
+| `--base-url` | `OH_API_BASE` | `https://app.all-hands.dev` |
+| `--sandbox-spec-id` | `SANDBOX_SPEC_ID` | none (account default image) |
+| `--poll-timeout` | `POLL_TIMEOUT` | `180` (seconds) |
+
+Neither script deletes the sandbox at the end so you can poke at it.
+Clean up with the `DELETE` endpoint — note the sandbox id goes in **both** the
+path and a required `sandbox_id` query parameter:
+
+```bash
+SID=
+curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/${SID}?sandbox_id=${SID}" \
+ -H "X-Session-API-Key: $OH_API_KEY"
+```
+
+## Notes
+
+- A freshly created sandbox starts in `STARTING`; `session_api_key` and
+ `exposed_urls` are `null` until it becomes `RUNNING`. Polling every few
+ seconds is sufficient.
+- The Cloud API has no single-sandbox `GET /sandboxes/{id}` — use the
+ batch-get endpoint `GET /sandboxes?id=` and read the first item.
+- To pick a specific runtime image, pass `?sandbox_spec_id=` to the
+ `POST /api/v1/sandboxes` call. List available specs with
+ `GET /api/v1/sandbox-specs/search`.
+
+## Related
+
+
+
+ Clone a repo and attach a conversation
+
+
+
+ Delete conversations and release PVCs
+
+
+
+ Full API documentation
+
+
diff --git a/docs.json b/docs.json
index 8572c832f..43a3769cd 100644
--- a/docs.json
+++ b/docs.json
@@ -647,12 +647,28 @@
"cookbook/index"
]
},
+ {
+ "group": "Sandbox lifecycle",
+ "pages": [
+ "cookbook/archive-sandbox",
+ "cookbook/clone-and-attach",
+ "cookbook/start-sandbox"
+ ]
+ },
{
"group": "Conversation monitoring & reacting",
"pages": [
"cookbook/conversation-tags"
]
},
+ {
+ "group": "Secrets & authentication",
+ "pages": [
+ "cookbook/gpg-commit-signing",
+ "cookbook/per-conversation-secrets",
+ "cookbook/service-account-github-pat"
+ ]
+ },
{
"group": "Guardrails",
"pages": [