diff --git a/cookbook/archive-sandbox.mdx b/cookbook/archive-sandbox.mdx
new file mode 100644
index 000000000..88f5a8618
--- /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@main (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..6ccbd1327
--- /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@main (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..e2a656a51 100644
--- a/cookbook/command-blacklist.mdx
+++ b/cookbook/command-blacklist.mdx
@@ -1,5 +1,5 @@
---
-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
---
@@ -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`](/cookbook/load-plugin) example:
```bash
cd ../load-plugin
@@ -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`](/cookbook/command-whitelist) example that shows the **whitelist** approach (only allow explicitly approved commands).
## Hook Types
@@ -190,30 +190,6 @@ This follows the **Claude Code plugin format**, compatible with:
- Claude Desktop plugin marketplace
- Any system supporting the `.claude-plugin` spec
-## Related
-
-
-
- Full hook documentation
-
-
-
- How plugins work
-
-
-
- Programmatic plugin loading
-
-
-
- No-code plugin launcher
-
-
-
- Whitelist approach (opposite strategy)
-
-
-
## Real-World Use Cases
- **Onboarding agents** - Prevent trainees from dangerous operations
@@ -240,3 +216,27 @@ fi
```
The inline bash makes it easy to iterate without rebuilding images or restarting servers.
+
+## Related
+
+
+
+ Full hook documentation
+
+
+
+ How plugins work
+
+
+
+ Programmatic plugin loading
+
+
+
+ No-code plugin launcher
+
+
+
+ Whitelist approach (opposite strategy)
+
+
diff --git a/cookbook/command-whitelist.mdx b/cookbook/command-whitelist.mdx
new file mode 100644
index 000000000..71ee92c18
--- /dev/null
+++ b/cookbook/command-whitelist.mdx
@@ -0,0 +1,242 @@
+---
+title: Command Whitelist
+description: Allow only approved shell commands with PreToolUse hooks (whitelist approach for strict security).
+icon: user-shield
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (command-whitelist/README.md). Edit the source, not this file. */}
+
+
+
+A self-contained example showing how to use **PreToolUse hooks** in a plugin to **whitelist approved shell commands**. The agent can only execute commands that are explicitly on the approved list - everything else is blocked.
+
+This example demonstrates the **whitelist approach**: deny everything by default, only allow specific approved commands.
+
+## What's in the Box
+
+The [`strict-mode/`](https://github.com/OpenHands/enterprise-cookbook/tree/main/command-whitelist/strict-mode) plugin bundles:
+
+- **Hooks** (`hooks/hooks.json`) - PreToolUse hook that validates commands against a whitelist
+- **Skill** (`skills/strict-mode/SKILL.md`) - Documentation about what's allowed
+- **Plugin manifest** (`.claude-plugin/plugin.json`) - Standard Claude Code plugin format
+
+## How It Works
+
+```mermaid
+flowchart TD
+ A["User: #quot;Install the requests package with pip#quot;"] --> B["Agent: *prepares terminal command: pip install requests*"]
+ B --> C["PreToolUse Hook: *intercepts before execution*"]
+ C --> C1["Extracts command name: #quot;pip#quot;"]
+ C1 --> C2["Checks whitelist: [ls, cat, grep, find, ...]"]
+ C2 --> C3["Not found in whitelist!"]
+ C3 --> C4["Returns exit code 2 (block) + explanation"]
+ C4 --> D["Agent: *receives block + reason, explains to user*"]
+ D --> E["User: *sees which commands are allowed*"]
+```
+
+## Whitelisted Commands
+
+Only these commands are allowed (all read-only operations):
+
+**File Operations:** `ls`, `cat`, `head`, `tail`, `file`
+**Search & Filter:** `grep`, `find`, `wc`
+**System Info:** `pwd`, `whoami`, `date`, `uname`, `df`, `du`, `stat`
+**Utilities:** `echo`, `which`, `env`, `printenv`, `history`, `tree`
+
+Everything else is **blocked by default**.
+
+## Run It
+
+
+
+ Use the companion [`load-plugin`](/cookbook/load-plugin) example:
+
+ ```bash
+ cd ../load-plugin
+
+ # This will be blocked (pip not whitelisted)
+ python load_plugin.py \
+ --repo-path command-whitelist/strict-mode \
+ --message "Install the requests package"
+
+ # This will be allowed (ls is whitelisted)
+ python load_plugin.py \
+ --repo-path command-whitelist/strict-mode \
+ --message "List all Python files in the current directory"
+ ```
+
+
+
+ Click to test strict mode:
+
+ [](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAiY29tbWFuZC13aGl0ZWxpc3Qvc3RyaWN0LW1vZGUifV0%3D\&message=Install%20the%20requests%20package)
+
+
+
+
+ To test the plugin from a branch before it's merged, pass `--ref ` to `load_plugin.py`.
+
+
+## The Hook
+
+The magic happens in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/main/command-whitelist/strict-mode/hooks/hooks.json):
+
+```json
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "terminal",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "input=$(cat)\n# ... POSIX-sh whitelist check ...\nexit 0",
+ "timeout": 5
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+**How it works:**
+
+1. **`PreToolUse`** - Runs **before** the terminal tool executes
+2. **`matcher: "terminal"`** - Only applies to shell commands
+3. **Inline POSIX-sh script** (run via `/bin/sh -c`; kept inline rather than a
+ `bash -c '...'` wrapper or an external `.sh` file — see the note in the
+ [command-blacklist README](/cookbook/command-blacklist#the-hook) for why):
+ - Extracts the command name from the JSON input
+ - Checks if it's in the hardcoded whitelist
+ - Returns `exit 0` (allow) or `exit 2` (block, with a `{"decision":"deny",...}` reason)
+
+The whitelist is maintained as a simple space-separated list in the script:
+
+```sh
+allowed="ls cat grep find head tail wc echo pwd whoami date uname df du tree file which env printenv history stat"
+```
+
+## Whitelist vs. Blacklist
+
+| Approach | Strategy | Security | Usability | Best For |
+| ------------------------------------------------------------------ | -------------------------------- | ----------------------------------------------- | -------------------------------------------- | ------------------------------------- |
+| **Whitelist** (this) | Deny by default, allow specific | ✅ **High** - Can't execute unexpected commands | ⚠️ **Limited** - Must pre-approve everything | High-security, read-only, educational |
+| **Blacklist** ([`command-blacklist`](/cookbook/command-blacklist)) | Allow by default, block specific | ⚠️ **Medium** - New patterns might slip through | ✅ **Full** - Everything works except blocked | General protection, development work |
+
+**Whitelist** = "Only these few things are allowed"
+**Blacklist** = "Everything is allowed except these specific things"
+
+## When to Use Each Approach
+
+### Use Whitelist (Strict Mode) When:
+
+- 🎓 **Educational** - Teaching safe command usage
+- 🔍 **Analysis only** - Reading/inspecting systems
+- 🛡️ **Maximum security** - Untrusted users or agents
+- 📊 **Auditing** - Examining existing systems
+- 🧪 **Sandboxes** - Limiting experimental environments
+
+### Use Blacklist (Safety Guardian) When:
+
+- 🚀 **Development** - Need full tooling access
+- 🔧 **General protection** - Block obvious dangers
+- ⚡ **Productivity** - Don't want to pre-approve everything
+- 🏗️ **Building** - Need to install, compile, deploy
+- 🎯 **Specific risks** - Known dangerous patterns to block
+
+## Extending the Whitelist
+
+To allow additional commands, edit the `allowed` list in `hooks/hooks.json`
+(add the command name, space-separated):
+
+```sh
+allowed="ls cat grep find ... git python npm"
+```
+
+Each command name is matched exactly - no wildcards or partial matches. For commands with subcommands (like `git clone`), you'll need to whitelist the main command (`git`) and handle subcommand validation separately if needed.
+
+## Security Considerations
+
+**✅ Strengths:**
+
+- Completely locks down command execution
+- Easy to audit (small whitelist)
+- Can't be bypassed by clever command variations
+- Works well for truly untrusted agents
+
+**⚠️ Limitations:**
+
+- Very restrictive (might frustrate users)
+- Requires updating the list as needs evolve
+- Doesn't prevent reading sensitive files (allows `cat /etc/passwd`)
+- Simple command extraction (not full shell parsing)
+
+For production security, consider:
+
+1. Combining with file path restrictions
+2. Adding argument validation (not just command name)
+3. Logging all blocked attempts
+4. Using a proper JSON parser instead of grep
+
+## Plugin Structure
+
+```text
+strict-mode/
+├── .claude-plugin/
+│ └── plugin.json # Plugin metadata
+├── hooks/
+│ └── hooks.json # PreToolUse hook definition
+└── skills/
+ └── strict-mode/
+ └── SKILL.md # Documentation (auto-loaded)
+```
+
+This follows the **Claude Code plugin format**, compatible with:
+
+- OpenHands Cloud plugin launcher
+- Claude Desktop plugin marketplace
+- Any system supporting the `.claude-plugin` spec
+
+## Real-World Use Cases
+
+- **Code review agents** - Only allow read operations on source code
+- **Security auditing** - Inspect systems without modification
+- **Student environments** - Safe learning sandbox
+- **Public demos** - Allow exploration without damage
+- **CI/CD read-only steps** - Verify without changing artifacts
+
+## Progressive Enhancement
+
+Start strict, then gradually expand:
+
+1. **Day 1:** Only allow `ls`, `cat`, `grep` (ultra-strict)
+2. **Week 1:** Add `find`, `wc`, `head`, `tail` (more inspection tools)
+3. **Month 1:** Add `git` for version control (read-only)
+4. **As needed:** Carefully evaluate and add new commands
+
+This way you build trust and understand usage patterns before opening up.
+
+## Related
+
+
+
+ Full hook documentation
+
+
+
+ How plugins work
+
+
+
+ Programmatic plugin loading
+
+
+
+ No-code plugin launcher
+
+
+
+ Blacklist approach (opposite strategy)
+
+
diff --git a/cookbook/conversation-metrics.mdx b/cookbook/conversation-metrics.mdx
new file mode 100644
index 000000000..dea555ef0
--- /dev/null
+++ b/cookbook/conversation-metrics.mdx
@@ -0,0 +1,272 @@
+---
+title: Conversation Metrics
+description: Retrieve cost and token usage metrics for conversations via CLI tool.
+icon: chart-line
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (conversation-metrics/README.md). Edit the source, not this file. */}
+
+
+
+CLI tool to retrieve cost and token usage metrics for OpenHands conversations.
+
+Supports both **V0** and **V1** APIs, automatically selecting the appropriate one based on the conversation version.
+
+## Features
+
+- **Auto-detects** conversation version (V0 vs V1) and uses the appropriate API
+- **Graceful fallback** chain for metric retrieval
+- **Displays** cost (USD), token counts, cache stats, and context window
+- **JSON output** for programmatic use
+- **API call logging** for debugging and development
+- **Fixture-based testing** - high coverage via recorded API responses
+- **Zero dependencies** - uses only Python standard library
+
+## How It Works
+
+The tool uses a fallback chain to find metrics:
+
+1. **Check conversation version** via `/api/conversations/{id}`
+2. **For V1 conversations**:
+ - First try `/api/v1/app-conversations?ids={id}` which includes a `metrics` object
+ - If metrics are all zeros, fall back to `/api/v1/conversation/{id}/events/search` and extract metrics from `ConversationStateUpdateEvent` at `value.stats.usage_to_metrics.agent`
+3. **For V0 conversations** (or if V1 fails): Use `/api/conversations/{id}/events` and find the latest event with `llm_metrics`
+4. **Last resort**: Use `/api/conversations/{id}/trajectory` and scan for `llm_metrics`
+
+
+ **Note:** Some V1 conversations have metrics stored only in events (not in the app-conversations response). The fallback chain ensures these are still retrieved correctly.
+
+
+## Installation
+
+No installation required - just make the script executable:
+
+```bash
+chmod +x oh-metrics
+```
+
+Requires Python 3.10+.
+
+## Run It
+
+### Set your API key
+
+```bash
+export OH_API_KEY="your-api-key"
+```
+
+### Get metrics for a conversation
+
+```bash
+./oh-metrics
+```
+
+**Example output (V0 conversation):**
+
+```text
+────────────────────────────────────────────────────────────
+Conversation: 7f3d57c4e5b2434d9ca78e5e27311137
+Title: Hello and a Programming Joke
+API Version: V0 via V0 (events)
+────────────────────────────────────────────────────────────
+💰 Total Cost: $0.083175 USD
+
+📊 Token Usage:
+ Prompt tokens: 12,865
+ Completion tokens: 117
+ Total tokens: 12,982
+
+🗄️ Cache:
+ Cache read: 0
+ Cache write: 12,740
+
+📐 Context window: 200,000
+────────────────────────────────────────────────────────────
+```
+
+**Example output (V1 conversation):**
+
+```text
+────────────────────────────────────────────────────────────
+Conversation: 72d40619b8534f9b9de6c3f17a71072d
+Title: 📝 V0 API for Conversation Costs & Tokens
+API Version: V1 via V1 (events)
+────────────────────────────────────────────────────────────
+💰 Total Cost: $13.576675 USD
+
+📊 Token Usage:
+ Prompt tokens: 17,577,146
+ Completion tokens: 63,985
+ Total tokens: 17,641,131
+
+🗄️ Cache:
+ Cache read: 17,022,477
+ Cache write: 553,973
+
+🧠 Reasoning tokens: 1,169
+
+📐 Context window: 200,000
+────────────────────────────────────────────────────────────
+```
+
+### JSON output
+
+```bash
+./oh-metrics --json
+```
+
+```json
+{
+ "conversation_id": "7f3d57c4e5b2434d9ca78e5e27311137",
+ "title": "Hello and a Programming Joke",
+ "api_version": "V0",
+ "api_used": "V0 (events)",
+ "metrics": {
+ "accumulated_cost": 0.083175,
+ "accumulated_token_usage": {
+ "prompt_tokens": 12865,
+ "completion_tokens": 117,
+ "cache_read_tokens": 0,
+ "cache_write_tokens": 12740,
+ "context_window": 200000
+ }
+ }
+}
+```
+
+### Options
+
+| Option | Short | Description |
+| ----------------- | ----- | ------------------------------------------------- |
+| `--api-key KEY` | `-k` | API key (defaults to `OH_API_KEY` env var) |
+| `--base-url URL` | `-u` | Base URL (default: `https://app.all-hands.dev`) |
+| `--json` | `-j` | Output in JSON format |
+| `--log-api-calls` | `-l` | Log all API requests/responses to `.oh/api-logs/` |
+| `--help` | `-h` | Show help message |
+
+### Logging API Calls
+
+For debugging or development, you can log all API requests and responses:
+
+```bash
+./oh-metrics --log-api-calls
+```
+
+This creates timestamped files in `.oh/api-logs/YYYYMMDD-HHMMSS/`:
+
+```text
+.oh/api-logs/20260227-145230/
+├── 0001-request.json
+├── 0001-response.json
+├── 0002-request.json
+└── 0002-response.json
+```
+
+Each request/response pair is numbered sequentially. The Authorization header is redacted from logged requests.
+
+## Metrics Explained
+
+| Field | Description |
+| -------------------- | ----------------------------------------- |
+| `accumulated_cost` | Total cost in USD |
+| `prompt_tokens` | Tokens sent to the LLM |
+| `completion_tokens` | Tokens generated by the LLM |
+| `cache_read_tokens` | Tokens read from prompt cache |
+| `cache_write_tokens` | Tokens written to prompt cache |
+| `reasoning_tokens` | Tokens used for reasoning (if applicable) |
+| `context_window` | Model's context window size |
+
+## Architecture
+
+The library is organized into separate modules:
+
+```text
+oh_api/
+├── __init__.py # Public API exports
+├── client.py # Base HTTP client with logging and fixture support
+├── v0.py # V0 API driver
+├── v1.py # V1 API driver
+└── metrics.py # High-level metrics retrieval with fallback chain
+```
+
+### Using the Library Programmatically
+
+```python
+from oh_api import APIClient, get_conversation_metrics
+
+# Create a client
+client = APIClient(
+ base_url="https://app.all-hands.dev",
+ api_key="your-api-key",
+ log_api_calls=True, # Optional: enable logging
+)
+
+# Get metrics
+metrics = get_conversation_metrics(client, "conversation-id")
+if metrics:
+ print(f"Cost: ${metrics.accumulated_cost:.6f}")
+ print(f"Tokens: {metrics.total_tokens:,}")
+```
+
+### Using the V0/V1 Drivers Directly
+
+```python
+from oh_api import APIClient
+from oh_api.v0 import V0Driver
+from oh_api.v1 import V1Driver
+
+client = APIClient(base_url="...", api_key="...")
+
+# V0 API
+v0 = V0Driver(client)
+conv = v0.get_conversation("conv-id")
+events = v0.get_events("conv-id", limit=50)
+
+# V1 API
+v1 = V1Driver(client)
+conv = v1.get_conversation("conv-id")
+if conv and conv.metrics:
+ print(f"Cost: ${conv.metrics.accumulated_cost}")
+```
+
+## Testing
+
+The test suite uses recorded API fixtures to achieve high coverage without making real API calls:
+
+```bash
+# Run tests
+uv run pytest tests/ -v
+
+# Run with coverage
+uv run pytest tests/ --cov --cov-report=term-missing
+```
+
+Current coverage: **89%** of library code.
+
+### Creating New Fixtures
+
+1. Run the CLI with `--log-api-calls` to capture real API responses
+2. Copy relevant response files to `tests/fixtures/`
+3. Rename following the pattern: `GET__api_path_q_param=value.json`
+
+## APIs Used
+
+### For V1 Conversations
+
+| Endpoint | Purpose |
+| --------------------------------------------- | -------------------------------------------------------- |
+| `GET /api/conversations/{id}` | Get conversation info (to determine version) |
+| `GET /api/v1/app-conversations?ids={id}` | Get conversation with metrics |
+| `GET /api/v1/conversation/{id}/events/search` | Fallback: find metrics in `ConversationStateUpdateEvent` |
+
+### For V0 Conversations
+
+| Endpoint | Purpose |
+| ---------------------------------------- | ----------------------------- |
+| `GET /api/conversations/{id}` | Get conversation info |
+| `GET /api/conversations/{id}/events` | Get events with `llm_metrics` |
+| `GET /api/conversations/{id}/trajectory` | Fallback for metrics |
+
+## License
+
+MIT License - see [LICENSE](https://github.com/OpenHands/enterprise-cookbook/blob/main/LICENSE) for details.
diff --git a/cookbook/conversation-tags.mdx b/cookbook/conversation-tags.mdx
index c6868c431..43e71cd11 100644
--- a/cookbook/conversation-tags.mdx
+++ b/cookbook/conversation-tags.mdx
@@ -1,5 +1,5 @@
---
-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
---
@@ -16,7 +16,7 @@ exactly this.
This is the supported replacement for adding a bespoke field (e.g. a custom
`environment_url` column) to the conversation model: use `tags` instead.
-## The two-server split
+## How It Works
OpenHands has a **Cloud app server** (manages accounts, sandboxes, and
conversations) and, for each sandbox, an **agent server** (the runtime that owns
@@ -44,14 +44,16 @@ immediately (`GET {conversation_url}` → `tags`). The Cloud's
up within a few seconds — so this example confirms the write on the agent server
and then *polls* the Cloud read instead of reading once.
-> Why not set tags on the Cloud create call? The Cloud
-> `POST/PATCH /api/v1/app-conversations` payloads do not expose `tags` today —
-> 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)).
+
+ Why not set tags on the Cloud create call? The Cloud
+ `POST/PATCH /api/v1/app-conversations` payloads do not expose `tags` today —
+ 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`](/cookbook/clone-and-attach)).
+
-## Tag rules
+## Tag Rules
The agent server enforces:
@@ -64,7 +66,7 @@ The agent server enforces:
Need to store something structured or longer than 256 chars? Put a JSON string
into a single tag value (within the limit), or split across multiple keys.
-## Run it
+## Run It
```bash
export OH_API_KEY=... # your https://app.all-hands.dev API key
@@ -100,7 +102,7 @@ round-trip OK: True
deleted sandbox 3NjFZz5JDyIVUdvxNsXi0R
```
-## Set your own tags
+## Set Your Own Tags
Pass `--tag KEY=VALUE` (repeatable), and `--keep` to leave the conversation open
so you can inspect the tags in the UI:
@@ -122,7 +124,7 @@ python tag_conversation.py \
| `--keep` | — | off | Don't delete the conversation/sandbox |
| `--poll-timeout` | `POLL_TIMEOUT` | `240` | Seconds to wait for readiness |
-## API endpoints used
+## APIs Used
| Endpoint | Server | Purpose |
| ------------------------------------------------ | ------ | ---------------------------------------------------------- |
diff --git a/cookbook/custom-agent-no-browser.mdx b/cookbook/custom-agent-no-browser.mdx
new file mode 100644
index 000000000..004825979
--- /dev/null
+++ b/cookbook/custom-agent-no-browser.mdx
@@ -0,0 +1,358 @@
+---
+title: Custom Agent No Browser
+description: Configure agent tools via the agent-server API to exclude the browser tool.
+icon: sliders
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-agent-no-browser/README.md). Edit the source, not this file. */}
+
+
+
+This example demonstrates **the correct pattern** for customizing agent tools using the OpenHands agent-server API. It configures an agent that has `terminal`, `file_editor`, and `task_tracker` but **no browser tool**, then verifies the result.
+
+## What This Example Shows
+
+- ✅ How to configure which tools the agent can access, (e.g. use standard tools, but exclude browser tools)
+- ✅ How to verify that the intended tools are present and excluded ones are absent
+- ✅ The complete workflow from sandbox creation to cleanup
+
+## The Key Insight: Two APIs
+
+OpenHands Cloud exposes **two different APIs**, and tool configuration happens on the second one:
+
+1. **Cloud API** (`https://app.all-hands.dev`) — manages the sandbox lifecycle (create, list, delete). Authenticated with your Cloud API key (`Authorization: Bearer $OH_API_KEY`).
+2. **Agent-server API** — runs *inside* each sandbox at the sandbox's own URL. It controls agent configuration (LLM, tools) and conversation execution. Authenticated with the sandbox's **session API key** (`X-Session-API-Key: {session_key}`).
+
+To customize tools you must talk to the **agent-server API**. The reliable way to do it is to **pass the tool list inline** in the `agent` object when you create the conversation:
+
+```python
+# session_key and agent_server_url come from the sandbox (see "How It Works").
+requests.post(
+ f"{agent_server_url}/api/conversations",
+ headers={"X-Session-API-Key": session_key},
+ json={
+ "agent": {
+ "llm": {
+ "model": "litellm_proxy/claude-sonnet-4-5-20250929",
+ "api_key": os.getenv("LLM_API_KEY"),
+ "base_url": os.getenv("LLM_BASE_URL"),
+ },
+ "tools": [
+ {"name": "terminal"},
+ {"name": "file_editor"},
+ {"name": "task_tracker"},
+ ],
+ },
+ "workspace": {"working_dir": "/workspace"},
+ "initial_message": {"content": [{"text": "Hello!"}]},
+ },
+)
+```
+
+Because the `agent` object defines the **whole** agent spec (LLM **and** tools) in a single request, the tools always take effect. The agent-server automatically adds the `finish` and `think` tools, so the resulting conversation exposes those two plus the three you asked for. The browser is excluded simply by not being in the list.
+
+
+ **Pitfall to avoid:** Do not set tools separately via `PATCH /api/settings` and then create the conversation with an `agent` object that contains only an `llm`. Sending an `agent` object without `tools` replaces the whole agent spec and drops the tools you configured, leaving the agent with just `finish` and `think`. Passing tools inline (as above) avoids this.
+
+
+## How It Works
+
+### 1. Create a Sandbox via the Cloud API
+
+```python
+response = requests.post(
+ "https://app.all-hands.dev/api/v1/sandboxes",
+ headers={"Authorization": f"Bearer {OH_API_KEY}"},
+)
+sandbox = response.json()
+sandbox_id = sandbox["id"]
+```
+
+Then poll `GET /api/v1/sandboxes?id={sandbox_id}` until `status == "RUNNING"`.
+A running sandbox gives you:
+
+- `id`: the sandbox identifier
+- `session_api_key`: authentication for the agent-server API
+- `exposed_urls`: a list of `{name, url, port}`; the agent-server URL is the
+ entry whose `name` is `AGENT_SERVER`
+
+```python
+session_key = sandbox["session_api_key"]
+agent_server_url = next(
+ u["url"] for u in sandbox["exposed_urls"] if u["name"] == "AGENT_SERVER"
+)
+```
+
+### 2. Create the Conversation with Tools Inline
+
+```python
+requests.post(
+ f"{agent_server_url}/api/conversations",
+ headers={"X-Session-API-Key": session_key},
+ json={
+ "agent": {
+ "llm": {"model": ..., "api_key": ..., "base_url": ...},
+ "tools": [
+ {"name": "terminal"},
+ {"name": "file_editor"},
+ {"name": "task_tracker"},
+ ],
+ },
+ "workspace": {"working_dir": "/workspace"},
+ "initial_message": {"content": [{"text": TASK}]},
+ },
+)
+```
+
+### 3. Run the Conversation
+
+```python
+requests.post(
+ f"{agent_server_url}/api/conversations/{conv_id}/run",
+ headers={"X-Session-API-Key": session_key},
+)
+```
+
+Then poll `GET /api/conversations/{conv_id}` until `execution_status` is
+`finished` (or `error`).
+
+### 4. Verify Tools
+
+The script performs two checks:
+
+**a) Available tools (from the `SystemPromptEvent`):** confirms every expected
+tool is present and no browser tool appears.
+
+```python
+response = requests.get(
+ f"{agent_server_url}/api/conversations/{conv_id}/events/search"
+ "?kind__eq=SystemPromptEvent&limit=1",
+ headers={"X-Session-API-Key": session_key},
+)
+available_tools = response.json()["items"][0]["tools"]
+available_titles = {t["title"] for t in available_tools}
+
+missing = [name for name in ["terminal", "file_editor", "task_tracker"]
+ if name not in available_titles]
+has_browser = any("browser" in t.get("title", "").lower() for t in available_tools)
+assert not missing and not has_browser
+```
+
+**b) Tools actually used (from `ActionEvent`s):** confirms no browser tool was
+invoked while performing the task.
+
+```python
+response = requests.get(
+ f"{agent_server_url}/api/conversations/{conv_id}/events/search",
+ headers={"X-Session-API-Key": session_key},
+)
+tools_used = {
+ event["tool_name"]
+ for event in response.json()["items"]
+ if event.get("kind") == "ActionEvent"
+}
+```
+
+### 5. Cleanup
+
+Delete the conversation, then the sandbox.
+
+```python
+requests.delete(
+ f"{agent_server_url}/api/conversations/{conv_id}",
+ headers={"X-Session-API-Key": session_key},
+)
+
+# DELETE /api/v1/sandboxes/{id} requires sandbox_id as BOTH a path segment AND a
+# query parameter; omitting the query parameter returns HTTP 422 and leaks the
+# sandbox.
+requests.delete(
+ f"https://app.all-hands.dev/api/v1/sandboxes/{sandbox_id}",
+ params={"sandbox_id": sandbox_id},
+ headers={"Authorization": f"Bearer {OH_API_KEY}"},
+)
+```
+
+## Run It
+
+### Prerequisites
+
+```bash
+pip install requests
+
+# OpenHands Cloud API key (for sandbox management)
+export OH_API_KEY=your-cloud-api-key
+
+# OpenHands LLM API key (get from Profile -> API Keys)
+export LLM_API_KEY=your-llm-api-key
+
+# LiteLLM proxy URL (default works for OpenHands Cloud)
+export LLM_BASE_URL=https://llm-proxy.app.all-hands.dev/
+```
+
+
+ **Important:** When using the agent-server API directly, you must provide both
+ `LLM_API_KEY` and `LLM_BASE_URL`. The agent-server needs to know where to send
+ LLM requests and how to authenticate with the LiteLLM proxy.
+
+
+### Run
+
+```bash
+python agent_no_browser.py
+```
+
+The script creates a sandbox, creates a conversation with the custom tools,
+runs a small task, verifies the tools, and deletes the sandbox. It exits with a
+non-zero status if verification fails.
+
+Expected output:
+
+```text
+=== Creating sandbox via Cloud API ===
+ sandbox: 2osAXsenK3xynchCyUvt4T
+ waiting for sandbox...
+ status: RUNNING
+ ✓ sandbox ready: 2osAXsenK3xynchCyUvt4T
+ agent-server: https://xxxx.prod-runtime.all-hands.dev
+
+=== Creating conversation ===
+ model: litellm_proxy/claude-sonnet-4-5-20250929
+ base_url: https://llm-proxy.app.all-hands.dev/
+ tools: terminal, file_editor, task_tracker
+ ✓ conversation created: 392e2589-...
+
+=== Running conversation ===
+ conversation already running
+ waiting for completion...
+ execution_status: running
+ ✓ conversation completed successfully
+
+=== Verifying tools ===
+
+ Available tools:
+ total tools: 5
+
+ 🔧 Core tools (5):
+ • terminal
+ • file_editor
+ • task_tracker
+ • finish
+ • think
+
+ ✅ PASS: all expected tools present: ['terminal', 'file_editor', 'task_tracker']
+ ✅ PASS: no browser tools in available tools list
+
+ Tools actually used: ['file_editor']
+ ✅ PASS: no browser tools were used
+
+=== Results ===
+View conversation: https://app.all-hands.dev/conversations/392e2589-...
+Agent-server: https://xxxx.prod-runtime.all-hands.dev
+
+=== Cleanup ===
+ ✓ deleted conversation 392e2589-...
+ ✓ deleted sandbox 2osAXsenK3xynchCyUvt4T
+
+✅ Success: agent configured with the expected tools (no browser).
+```
+
+
+ The exact tool set can vary with your account configuration (for example, MCP
+ integrations may add more tools). What this example guarantees is that the
+ three requested tools are present and no browser tool is included.
+
+
+### Keep Resources for Inspection
+
+```bash
+python agent_no_browser.py --keep
+```
+
+This skips cleanup so you can inspect the conversation in the UI.
+
+## Available Tools
+
+Common tool names you can include:
+
+- `terminal` - Execute bash commands
+- `file_editor` - Read/write/edit files
+- `task_tracker` - Track tasks and progress
+- `browser_tool_set` - Web browser automation (excluded in this example)
+
+## Common Issues
+
+### "Unauthorized" error
+
+Make sure you're using the **session API key** for agent-server calls, not your
+Cloud API key:
+
+```python
+# Wrong - Cloud API key on the agent-server
+headers = {"Authorization": f"Bearer {OH_API_KEY}"}
+
+# Correct - session API key
+headers = {"X-Session-API-Key": session_key}
+```
+
+### Only `finish` and `think` are present
+
+You almost certainly created the conversation with an `agent` object that
+contained an `llm` but no `tools`. Pass the tools inline in the same request (see
+"The Key Insight" above).
+
+### HTTP 422 when deleting a sandbox
+
+`DELETE /api/v1/sandboxes/{id}` also requires `sandbox_id` as a query parameter:
+`DELETE /api/v1/sandboxes/{id}?sandbox_id={id}`.
+
+### Conversation never finishes
+
+The default timeout is 3 minutes. If your task is complex, inspect the
+conversation in the UI (`https://app.all-hands.dev/conversations/{conv_id}`) or
+fetch its events, and consider a simpler task or a longer timeout.
+
+## Architecture Notes
+
+```mermaid
+flowchart TD
+ A["Cloud API (app.all-hands.dev) - Authentication (Bearer OH_API_KEY) - Sandbox lifecycle (create / list / delete)"] -->|creates| B["Agent-Server (sandbox-specific runtime URL) - Auth via session API key (X-Session-API-Key) - Agent configuration (LLM, tools) - Conversation execution"]
+```
+
+**Key insight:** Agent customization happens at the **agent-server level**. The
+simplest, reliable way to set tools is to pass them inline in the `agent` object
+when creating the conversation.
+
+## Next Steps
+
+- See `../custom-agent-with-tool/` for adding completely custom tools.
+
+## APIs Used
+
+### Key Agent-Server APIs Used
+
+| Endpoint | Method | Purpose |
+| --------------------------------------- | ------ | -------------------------------------------------------- |
+| `/api/conversations` | POST | Create conversation (pass `agent.llm` and `agent.tools`) |
+| `/api/conversations/{id}/run` | POST | Start conversation execution |
+| `/api/conversations/{id}` | GET | Get conversation status |
+| `/api/conversations/{id}/events/search` | GET | Get conversation events (tools, actions) |
+| `/api/conversations/{id}` | DELETE | Delete the conversation |
+
+### Cloud APIs Used
+
+| Endpoint | Method | Purpose |
+| ---------------------------------------- | ------ | ----------------------------------------- |
+| `/api/v1/sandboxes` | POST | Create a sandbox |
+| `/api/v1/sandboxes?id={id}` | GET | Poll sandbox status / read `exposed_urls` |
+| `/api/v1/sandboxes/{id}?sandbox_id={id}` | DELETE | Delete the sandbox |
+
+## Related
+
+
+
+
+
+
+
+
diff --git a/cookbook/custom-agent-with-pip-tool.mdx b/cookbook/custom-agent-with-pip-tool.mdx
new file mode 100644
index 000000000..42addf8f2
--- /dev/null
+++ b/cookbook/custom-agent-with-pip-tool.mdx
@@ -0,0 +1,160 @@
+---
+title: Custom Pip Tool Agent
+description: Load a custom tool from a published pip package via pip install --target and tool_module_qualnames.
+icon: box
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-agent-with-pip-tool/README.md). Edit the source, not this file. */}
+
+
+
+Load a custom tool from a **published Python package** in OpenHands Cloud. The package
+is installed via `pip install --target /workspace`, making it importable by the frozen
+agent-server, which then loads and registers the tool via `tool_module_qualnames`.
+
+This is the natural evolution of `custom-agent-with-tool`: same loading mechanism
+(declare the tool in `agent.tools`, map it in `tool_module_qualnames`), but the
+*deploy* step becomes **"pip-install-into-working-dir"** instead of
+**"upload source files."**
+
+The example uses **[oh-markdown-tool](https://github.com/jpshackelford/oh-markdown-tool)**
+(`oh-markdown-tool[openhands]==0.2.1`), a real published package that provides structural
+markdown editing (renumber sections, manage TOC, etc.). The agent fixes a markdown doc
+with messy numbering and adds a table of contents.
+
+## How It Works
+
+1. **Create a sandbox** (`POST /api/v1/sandboxes`) and wait for `RUNNING`.
+2. **Install the package** into `/workspace` via `pip install --target`:
+ ```bash
+ pip install --target /workspace --no-deps oh-markdown-tool[openhands]==0.2.1
+ pip install --target /workspace mdformat pymarkdownlnt
+ ```
+ The `--target` flag puts the package into the conversation's working directory,
+ which is on the agent-server's `sys.path`. We use `--no-deps` to skip dependencies
+ that are already **bundled** in the frozen agent-server (`openhands-sdk`, `pydantic`,
+ `rich`), then install only the **non-bundled** deps (`mdformat`, `pymarkdownlnt`)
+ explicitly.
+3. **Create a sample markdown file** with messy numbering (sections 5, 10, 3).
+4. **Create a conversation** that maps the tool via `tool_module_qualnames`:
+ ```python
+ "agent": {
+ "tools": [
+ {"name": "terminal"},
+ {"name": "file_editor"},
+ {"name": "markdown_document"}, # the custom tool
+ ]
+ },
+ "tool_module_qualnames": {"markdown_document": "oh_markdown_tool.tool"},
+ ```
+ The agent-server imports `oh_markdown_tool.tool`, which registers the tool (the
+ package's `tool.py` calls `register_tool("markdown_document", ...)` on import).
+5. **Run and verify**:
+ - The tool appears in `SystemPromptEvent.tools` (registered).
+ - The tool appears in `ActionEvent` records (used).
+ - The file is actually modified (TOC added, sections renumbered 1, 2, 3).
+
+## Prerequisites
+
+```bash
+pip install requests
+export OH_API_KEY=your-openhands-cloud-api-key
+export LLM_API_KEY=your-llm-api-key
+```
+
+Optional overrides (sensible defaults are used otherwise):
+
+```bash
+export LLM_MODEL=litellm_proxy/claude-sonnet-4-5-20250929
+export LLM_BASE_URL=https://llm-proxy.app.all-hands.dev/
+```
+
+## Run It
+
+```bash
+python working_example.py # runs and cleans up the sandbox
+python working_example.py --keep # leave the sandbox up for inspection
+```
+
+
+ **⚠️ Timing note**: If the script runs and deletes the sandbox too quickly, conversation
+ events may not have synced from the agent-server to the main API yet, making the
+ conversation appear empty or incomplete in the Cloud UI. To inspect conversation events
+ in real time, **run with `--keep`** to leave the sandbox alive, or add a delay before
+ cleanup. The script prints the conversation URL — you can view it while the sandbox is
+ still running.
+
+
+Expected output:
+
+```text
+[demo] === Verification ===
+[demo] registered tools: ['terminal', 'file_editor', 'Markdown Document Tool', 'finish', 'think']
+[demo] tools used: ['file_editor', 'markdown_document']
+[demo] PASS: tool 'markdown_document' is registered
+[demo] PASS: tool 'markdown_document' was invoked by the agent
+[demo] PASS: file was modified (TOC added, sections renumbered)
+[demo] SUCCESS: the tool was loaded from the pip package and used.
+```
+
+## Why `pip install --target` (and Not Plain `pip install`)?
+
+On OpenHands Cloud the agent-server is a **frozen, self-contained binary** (built with
+PyInstaller). A plain `pip install` targets the sandbox's *system* Python interpreter,
+which the frozen server cannot see — so the module would never be importable.
+
+The conversation's **working directory is on the agent-server's import path**, so
+`pip install --target /workspace` places the package where the server can actually
+import it. The bundled dependencies (`openhands-sdk`, `pydantic`, `rich`) are already
+available inside the frozen binary, so we skip them (`--no-deps`) and only install the
+package's own code plus its *non-bundled* dependencies.
+
+This approach works for any published package, not just `oh-markdown-tool` — the key is
+installing into the working directory and being aware of what's already bundled.
+
+## Verification (Same Technique as the Other Examples)
+
+Both this example and `custom-agent-with-tool` read the `SystemPromptEvent.tools` array
+to confirm registration, and the `ActionEvent` records to confirm usage. This example
+adds a third check: **did the tool actually do something?** We read the markdown file
+after the run and confirm it was modified (TOC present, sections renumbered).
+
+## The Tool: `oh-markdown-tool`
+
+`oh-markdown-tool` is a real, published package ([PyPI](https://pypi.org/project/oh-markdown-tool/) |
+[GitHub](https://github.com/jpshackelford/oh-markdown-tool)) that provides structural
+markdown editing. It's built with the OpenHands SDK and demonstrates the full packaging
+story: core library (no SDK dependency) + optional `[openhands]` extra for the agent
+tool integration.
+
+The tool offers commands like:
+
+- `overview` — show document structure
+- `renumber` — fix section numbering
+- `toc_update` — add/update table of contents
+- `move`, `insert`, `delete`, `promote`, `demote` — section operations
+- `rewrap`, `lint`, `fix`, `cleanup` — formatting
+
+For this example we use `renumber` + `toc_update` to fix a messy doc.
+
+## Files
+
+| File | Purpose |
+| -------------------- | ------------------------------------------------------------------------- |
+| `working_example.py` | End-to-end runner: create sandbox → pip install → run → verify → clean up |
+
+## Cleanup
+
+Without `--keep` the sandbox is deleted automatically. With `--keep`, delete it when
+done:
+
+```bash
+curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/?sandbox_id=" \
+ -H "Authorization: Bearer $OH_API_KEY"
+```
+
+
+ Note: `DELETE /api/v1/sandboxes/{id}` requires `sandbox_id` as **both** the path
+ segment and a query parameter; omitting the query parameter returns HTTP 422 and
+ leaks the sandbox.
+
diff --git a/cookbook/custom-agent-with-tool.mdx b/cookbook/custom-agent-with-tool.mdx
new file mode 100644
index 000000000..490e64c4b
--- /dev/null
+++ b/cookbook/custom-agent-with-tool.mdx
@@ -0,0 +1,166 @@
+---
+title: Custom Agent With Tool
+description: Add custom server-side tools via source file upload and tool_module_qualnames.
+icon: wrench
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-agent-with-tool/README.md). Edit the source, not this file. */}
+
+
+
+Add a **completely custom, server-side tool** to an OpenHands Cloud agent — without
+forking or rebuilding the agent-server. The agent-server loads your tool at
+conversation-creation time via the `tool_module_qualnames` mechanism.
+
+The example ships a small demo tool (the **Bureau of Bug Registration**) and proves,
+end to end, that the agent **registers**, **uses**, and **reports output from** it.
+
+Why this tool? It does something an LLM would never produce on its own and **could not
+fake**: it assigns each bug a deterministic, hash-derived **Case ID** (e.g. `BUG-59C20D`)
+plus a gloriously bureaucratic classification. Because the Case ID is a SHA-256 slice
+of the bug report, the only way the agent's answer can contain the correct ID is if
+it actually called the tool — making the demonstration unfalsifiable.
+
+## How It Works
+
+1. **Create a sandbox** (`POST /api/v1/sandboxes`) and wait for `RUNNING`. Read the
+ `AGENT_SERVER` URL and the `session_api_key` from the sandbox record.
+2. **Deploy the tool** as an importable package in the conversation's working
+ directory, `/workspace/bug_registry/`, using the agent-server file API
+ (`POST /api/file/upload?path=...`). Two files are uploaded: `__init__.py` and
+ `tool.py` (the contents of `custom_tool_definition.py`).
+3. **Create a conversation** (`POST /api/conversations`) that lists the tool in
+ `agent.tools` and maps it in `tool_module_qualnames`.
+4. **Run and verify** (`POST .../run`, then read events). The custom tool must show
+ up in the `SystemPromptEvent.tools` list (registered) and as an `ActionEvent`
+ `tool_name` (used).
+
+### Why upload into the working directory (and not `pip install`)?
+
+On OpenHands Cloud the agent-server is a **frozen, self-contained binary** (built
+with PyInstaller). A normal `pip install` targets a *different* Python interpreter
+that the frozen server cannot see, so the module would never be importable and the
+tool would fail to register.
+
+The conversation's **working directory is on the agent-server's import path**, so
+dropping the package there makes `import rubber_duck.tool` work with no install
+step. The tool's own imports (`openhands.sdk`, `pydantic`) resolve from inside the
+frozen server, where they're always available.
+
+Uploading the file (rather than writing it through a shell heredoc) also means the
+**tool source can contain anything** — quotes, `EOF` markers, backslashes — with no
+escaping or injection pitfalls.
+
+## The Core Idea: Declare + Locate
+
+Creating a conversation with a custom tool is a two-part contract:
+
+- `agent.tools` — **which** tools to activate, by name:
+ `[{"name": "terminal"}, {"name": "file_editor"}, {"name": "bug_registry"}]`
+- `tool_module_qualnames` — **where** a tool comes from, as an importable module:
+ `{"bug_registry": "bug_registry.tool"}`
+
+On conversation creation the agent-server imports each mapped module. Importing has
+a **side effect**: the module's `register_tool("bug_registry", BugRegistryTool)` call
+runs and registers the tool. If the name is requested but nothing registered it,
+the server returns `ToolDefinition '' is not registered`.
+
+Built-in tools (`terminal`, `file_editor`, ...) don't need an entry — the server
+already knows where those live. **Only your custom tools need a qualname mapping.**
+
+## Prerequisites
+
+```bash
+pip install requests
+export OH_API_KEY=your-openhands-cloud-api-key
+export LLM_API_KEY=your-llm-api-key
+```
+
+Optional overrides (sensible defaults are used otherwise):
+
+```bash
+export LLM_MODEL=litellm_proxy/claude-sonnet-4-5-20250929
+export LLM_BASE_URL=https://llm-proxy.app.all-hands.dev/
+```
+
+## Run It
+
+```bash
+python working_example.py # runs and cleans up the sandbox
+python working_example.py --keep # leave the sandbox up for inspection
+```
+
+
+ **⚠️ Timing note**: If the script runs and deletes the sandbox too quickly, conversation
+ events may not have synced from the agent-server to the main API yet, making the
+ conversation appear empty or incomplete in the Cloud UI. To inspect conversation events
+ in real time, **run with `--keep`** to leave the sandbox alive, or add a delay before
+ cleanup. The script prints the conversation URL — you can view it while the sandbox is
+ still running.
+
+
+Expected output:
+
+```text
+[demo] === Verification ===
+[demo] registered tools: ['terminal', 'file_editor', 'bug_registry', 'finish', 'think']
+[demo] tools used: ['bug_registry']
+[demo] Case ID issued by tool: BUG-59C20D
+[demo] PASS: custom tool 'bug_registry' is registered
+[demo] PASS: custom tool 'bug_registry' was used by the agent
+[demo] PASS: agent reported the tool's Case ID (BUG-59C20D) in its answer
+[demo] SUCCESS: the custom tool was loaded and used in OpenHands Cloud.
+```
+
+## Verifying Registration (Same Technique as `custom-agent-no-browser`)
+
+The agent-server records a `SystemPromptEvent` whose `tools` array is the exact set
+of tools the agent was given. Both examples read it the same way:
+
+```python
+system_events = [e for e in items if e.get("kind") == "SystemPromptEvent"]
+registered = [t.get("title") for t in system_events[0].get("tools", [])]
+assert "bug_registry" in registered # registered / available
+assert "bug_registry" in tools_used # actually invoked (from ActionEvents)
+```
+
+The verification also extracts the **Case ID** from the tool's observation (the ground
+truth) and confirms that exact ID appears in the agent's final answer — proving the
+tool's output shaped the response and the agent didn't fabricate it.
+
+## The Example Tool
+
+`custom_tool_definition.py` defines a `bug_registry` tool with the OpenHands SDK: a
+`ToolDefinition` + typed `Action`/`Observation` + an `Executor`. It takes a `problem`
+(and optional `code`) and deterministically derives a **hash-based Case ID** plus an
+absurd bureaucratic classification ("Haunted Copy-Paste Residue", "Gaslighting Boolean",
+etc.) from the SHA-256 of the report. Importing the module registers the tool.
+
+The prompt in `working_example.py` is **directive** — it explicitly asks the agent to
+file the bug and report the official Case ID — so the happy path is deterministic and
+unfalsifiable: the hash-derived ID can only come from the tool. This proves the tool is
+registered, callable, returns output, and that output demonstrably reaches the agent's
+answer.
+
+## Files
+
+| File | Purpose |
+| --------------------------- | ----------------------------------------------------------------------------- |
+| `working_example.py` | End-to-end runner: create sandbox -> deploy tool -> run -> verify -> clean up |
+| `custom_tool_definition.py` | The custom tool, implemented with the OpenHands SDK (uploaded as `tool.py`) |
+
+## Cleanup
+
+Without `--keep` the sandbox is deleted automatically. With `--keep`, delete it when
+done:
+
+```bash
+curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/?sandbox_id=" \
+ -H "Authorization: Bearer $OH_API_KEY"
+```
+
+
+ Note: `DELETE /api/v1/sandboxes/{id}` requires `sandbox_id` as **both** the path
+ segment and a query parameter; omitting the query parameter returns HTTP 422 and
+ leaks the sandbox.
+
diff --git a/cookbook/custom-system-prompt.mdx b/cookbook/custom-system-prompt.mdx
new file mode 100644
index 000000000..38f9a4bae
--- /dev/null
+++ b/cookbook/custom-system-prompt.mdx
@@ -0,0 +1,425 @@
+---
+title: Custom System Prompt
+description: Override the default OpenHands system prompt with a custom one for specialized agents.
+icon: message
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-system-prompt/README.md). Edit the source, not this file. */}
+
+
+
+Override the OpenHands system prompt for one conversation with your own text,
+using the Cloud **App Conversation API**.
+
+`POST /api/v1/app-conversations` accepts a `system_prompt` field. When set,
+it **replaces** the default static system prompt for that conversation. The
+per-conversation dynamic context — repo context, loaded skills, custom
+secrets, current datetime — is still appended automatically, so your custom
+prompt composes with whatever runtime context the sandbox provides.
+
+Feature shipped in OpenHands Enterprise **1.62.0**
+([enterprise#335](https://github.com/OpenHands/enterprise/pull/335)) and is
+also live on OpenHands Cloud.
+
+## When to Use This
+
+- Your agent has a specialised role (research assistant, code reviewer,
+ documentation writer) and the default coding-agent framing gets in the way.
+- You're building a domain-specific product on top of OpenHands and want to
+ own the prompt without asking the agent to *ignore* the default one.
+- You need to enforce specific behaviour or persona for a single
+ conversation without changing user- or org-level settings.
+
+For long-lived overrides, put the text in an **agent profile** instead;
+`system_prompt` is a per-conversation override.
+
+## What Gets Replaced, and What Doesn't
+
+`SystemPromptEvent` is the first event in every conversation and carries
+two structured fields:
+
+| Field | Contains | Overridden by `system_prompt`? |
+| ----------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
+| `system_prompt` | The static system prompt (a `{cache_prompt, type, text}` object) | **Yes** — its `.text` becomes your custom prompt verbatim |
+| `dynamic_context` | Runtime context: repo context, ``, ``, ``, host info | No — still appended by the server |
+
+`system_message_suffix` is a *separate* field on the same request and is
+appended to the dynamic context; you can set both.
+
+## Anatomy of the Default System Prompt
+
+Before you replace the default prompt wholesale, it's worth knowing what
+you're displacing. The SDK composes the prompt from a small set of named,
+guarded sections. The references below are pinned to
+[`software-agent-sdk@v1.50.1`](https://github.com/OpenHands/software-agent-sdk/tree/v1.50.1)
+so the line numbers and content stay stable; bump the tag when auditing a
+newer release.
+
+### Two blocks on one system message
+
+The default agent ships the prompt as a `SystemPromptEvent` with two
+content blocks on a single `role: system` message
+([`event/llm_convertible/system.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/event/llm_convertible/system.py)):
+
+| Block | Field on the event | What it carries | Cacheable |
+| ----------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| 1 (static) | `system_prompt.text` | Identity, role, work habits, security policy, model-specific rules | **Yes** — marked `cache_prompt=True` |
+| 2 (dynamic) | `dynamic_context.text` | Current datetime, repo context, loaded ``, custom ``, your `system_message_suffix` | **No** — left unmarked so the static prefix stays shared across conversations |
+
+The caching split is wired in
+[`llm/llm.py::_apply_prompt_caching`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/llm/llm.py)
+— for Anthropic-style prefix caching, only index 0 gets the cache marker.
+Every conversation your account starts can hit the same cached static
+block, which is the whole reason the two-block shape exists.
+
+### What goes into each block
+
+Static-tier sections live in
+[`context/prompts/sections/static.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/context/prompts/sections/static.py);
+dynamic-tier sections in
+[`context/prompts/sections/dynamic.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/context/prompts/sections/dynamic.py).
+The default composition order is pinned in
+[`context/prompts/presets.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/context/prompts/presets.py):
+
+**Static block, in order** — `` and `` (identity), ``
+(AGENTS.md or persistent-memory guidance), ``,
+``, ``, ``,
+``, ``, ``
+(work habits), `` and `` (safety
+policy), `` (only if `enable_browser`),
+``, ``, ``,
+``, and `` (per-model-family tweaks for
+Claude, Gemini, GPT-5).
+
+**Dynamic block, in order** — ``, ``,
+``, your `system_message_suffix` (raw, no wrapper),
+``, and `` **last** on purpose: it's
+the only per-conversation volatile value, so putting it at the tail keeps
+the stable dynamic content a cache-friendly prefix even on providers that
+cache the dynamic block too.
+
+Setting `system_prompt` on `POST /api/v1/app-conversations` **replaces the
+entire static block above** — all 17-ish sections, verbatim text and all —
+with your custom text. The dynamic block is unaffected.
+
+### Writing a custom `system_prompt` without breaking caching
+
+The static block's whole value is that it's the same bytes every time your
+account hits the LLM. Anthropic's prefix cache keys on the exact byte
+prefix: a one-character change splits one cached prefix into two, each
+with its own cold-start cost on the next miss. OpenAI's and Gemini's
+caches have the same shape of pitfall. So when you write your own
+`system_prompt`:
+
+- **Don't interpolate per-conversation volatile values** into the text.
+ No `datetime.now()`, no request id, no conversation id, no user email,
+ no repo URL, no sandbox id, no working directory. Any of these in the
+ static block means every conversation gets its own cache entry —
+ effectively no caching at all.
+- **Don't interpolate per-user profile fields** either (API key, display
+ name, org name). Even if the value changes rarely, it still shards the
+ cache per-user, and anything secret-shaped doesn't belong in a cached
+ blob.
+- **If you need date-aware behaviour, read the dynamic block.** The server
+ always appends `` to `dynamic_context` — reference it
+ from the static text (e.g. "see `` for today's date")
+ instead of baking a timestamp in.
+- **Keep the exact same bytes across runs that are logically "the same
+ agent".** If you tweak wording, do it on a deploy boundary, not inside
+ the request path.
+- **Front-load the parts you're least likely to edit.** On a tail-only
+ edit, the longest unchanged prefix still hits the cache; on a prefix
+ edit, nothing does.
+- **If you need per-conversation flavour, use `system_message_suffix`**
+ (goes into the uncached dynamic block) rather than templating it into
+ `system_prompt`.
+
+A sanity check: hash the string you're about to send
+(`hashlib.sha256(system_prompt.encode()).hexdigest()[:12]`) and log it. If
+that hash changes between two conversations that should be equivalent, you
+have a cache leak.
+
+### `system_prompt` vs. skills vs. `system_message_suffix` — when to use which
+
+All three let you shape the agent's instructions, but they pay very
+different context-window and caching costs:
+
+| Mechanism | Where it lands | Cost | Use for |
+| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
+| `system_prompt` | Cached static block | Paid once per unique prompt, then free (within the cache window) | Identity, tone, hard rules, response format — things the agent should see on **every** turn of **every** conversation |
+| **Skills** (`disabled_skills` on the start request, or your own via the plugin / sandbox-upload paths) | Dynamic `` block, with per-skill bodies loaded only on trigger | Catalog line costs tokens every turn; the full skill body only costs tokens on turns it fires | Specialised procedures, long reference material, service-specific recipes — things that matter **sometimes** |
+| `system_message_suffix` | Dynamic block (uncached), appended after `` | Paid every turn, every conversation it's set on | Short per-conversation nudges that compose with the default without rewriting the full static text |
+
+Rule of thumb — **if the instruction applies to every turn of every
+conversation this agent runs, it belongs in `system_prompt`. If it
+applies sometimes, make it a skill. If it's a one-conversation tweak,
+use `system_message_suffix`.**
+
+Resist the urge to pack long domain procedures into `system_prompt` just
+because it feels tidier. Every token in the static block is a token the
+LLM reads before responding to anything — a skill that fires on 1-in-20
+turns pays its full-body cost \~5% as often as the static block does.
+
+## How It Works
+
+### 1. Start the conversation
+
+`POST /api/v1/app-conversations` is asynchronous: it returns a
+[`AppConversationStartTask`](/) whose `id` is the
+**task id**, not the conversation id. Omit `sandbox_id` and the App Server
+provisions a fresh sandbox for you.
+
+```python
+payload = {
+ "system_prompt": "You are a research assistant...",
+ "initial_message": {
+ "role": "user",
+ "content": [{"type": "text", "text": "Say hello."}],
+ },
+ "title": "custom-system-prompt demo",
+}
+task = requests.post(
+ f"{base_url}/api/v1/app-conversations",
+ headers={"Authorization": f"Bearer {api_key}"},
+ json=payload,
+).json()
+task_id = task["id"]
+```
+
+### 2. Wait for `app_conversation_id`
+
+Poll `/api/v1/app-conversations/start-tasks?ids=` until the task
+reaches `READY` and hands back `app_conversation_id` (and, if the server
+provisioned one, `sandbox_id`). The endpoint is a "search by ids"
+lookup — `ids` is a required, repeatable query parameter and the
+response is a JSON array of the matching tasks.
+
+```python
+while not conv_id:
+ time.sleep(3)
+ item = requests.get(
+ f"{base_url}/api/v1/app-conversations/start-tasks",
+ headers=headers,
+ params={"ids": task_id},
+ ).json()[0]
+ if item["status"] == "ERROR":
+ raise RuntimeError(item.get("detail"))
+ conv_id = item.get("app_conversation_id")
+ sandbox_id = item.get("sandbox_id") or sandbox_id
+```
+
+### 3. Verify from the `SystemPromptEvent`
+
+The Cloud events endpoint mirrors the agent-server view, so you can read
+the event without needing the sandbox's `session_api_key`.
+
+```python
+event = requests.get(
+ f"{base_url}/api/v1/conversation/{conv_id}/events/search",
+ headers=headers,
+ params={"kind__eq": "SystemPromptEvent", "limit": 1},
+).json()["items"][0]
+
+assert custom_prompt in event["system_prompt"]["text"]
+```
+
+Note the shape: `event["system_prompt"]` is a `{cache_prompt, type, text}`
+object — the actual prompt string is at `.text`.
+
+### 4. Cleanup
+
+`DELETE /api/v1/app-conversations/{id}` frees the conversation. Delete the
+sandbox separately when you're done with it. The sandbox delete endpoint
+is `DELETE /api/v1/sandboxes/{id}` and additionally requires `sandbox_id`
+as a query parameter — pass the same id in both places, or the server
+returns HTTP 422.
+
+```python
+requests.delete(f"{base_url}/api/v1/app-conversations/{conv_id}", headers=headers)
+requests.delete(
+ f"{base_url}/api/v1/sandboxes/{sandbox_id}",
+ headers=headers,
+ params={"sandbox_id": sandbox_id},
+)
+```
+
+## Prerequisites
+
+```bash
+pip install requests
+export OH_API_KEY="your-api-key" # Cloud: Profile → API Keys.
+ # OHE: Settings → API Keys on your instance.
+```
+
+You do **not** need to set LLM credentials — the App Server uses whatever
+LLM your account is configured with.
+
+## Run It
+
+```bash
+python custom_system_prompt.py
+
+# or against an OpenHands Enterprise instance:
+python custom_system_prompt.py --base-url https://your-ohe.example.com
+
+# supply your own prompt / message:
+python custom_system_prompt.py \
+ --prompt "You are Sir Reginald, a pirate. Every reply starts with 'Arrr!'" \
+ --message "Say hello."
+
+# keep resources for inspection (no cleanup at the end):
+python custom_system_prompt.py --keep
+```
+
+The script starts a conversation with `system_prompt` set, fetches the first
+`SystemPromptEvent`, asserts the custom text is there, and cleans up.
+Exit status is non-zero if verification fails.
+
+### Real output
+
+Captured against an OpenHands Enterprise 1.67.0 instance:
+
+```text
+=== start conversation ===
+ custom prompt: You are a research assistant. Answer questions concisely and accurately. When wr...
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: SETTING_UP_SKILLS
+ start-task status: SETTING_UP_SKILLS
+ start-task status: STARTING_CONVERSATION
+ start-task status: STARTING_CONVERSATION
+ start-task status: READY
+conversation: 7698ab13bb994b3181ea01d23652a2ca
+sandbox: 3U13tOWV00GZpo1bhnhMBf
+
+=== verify SystemPromptEvent ===
+ system_prompt.text (124 chars): You are a research assistant. Answer questions concisely and accurately. When writing code, focus on clarity and simplic...
+ dynamic_context.text (47806 chars) — appended automatically; contains repo/skills/secrets/datetime blocks
+
+ PASS: custom prompt found in SystemPromptEvent
+
+=== cleanup ===
+ deleted conversation 7698ab13bb994b3181ea01d23652a2ca
+ deleted sandbox 3U13tOWV00GZpo1bhnhMBf
+```
+
+## Prompt Gallery
+
+Swap `--prompt` (or `SYSTEM_PROMPT`) for any of these to try them out.
+
+### Research assistant
+
+```text
+You are a research assistant. Answer questions concisely and accurately.
+When writing code, focus on clarity and simplicity. Always cite your sources
+when making factual claims.
+```
+
+### Code reviewer
+
+```text
+You are a code reviewer. Analyze code for correctness, security, and
+maintainability. Provide specific, actionable feedback. Focus on material
+issues, not style preferences.
+```
+
+### Documentation writer
+
+```text
+You are a technical documentation writer. Write clear, accurate, and
+comprehensive documentation. Use examples to illustrate concepts.
+Structure content with clear headings and logical flow.
+```
+
+### Data analyst
+
+```text
+You are a data analyst. Analyze data to extract insights and patterns.
+Use appropriate statistical methods and visualizations. Explain your
+findings clearly with supporting evidence.
+```
+
+## Composition with `system_message_suffix`
+
+`system_message_suffix` is a separate field appended to the dynamic
+context. You can use both together:
+
+```python
+payload = {
+ "system_prompt": "You are a research assistant...",
+ "system_message_suffix": "Focus on academic papers published after 2020.",
+ ...
+}
+```
+
+The resulting prompt structure is:
+
+```text
+[system_prompt.text — your custom static prompt]
+
+[dynamic_context.text — repo context, skills, secrets, datetime]
+[system_message_suffix — appended to the dynamic context]
+```
+
+## Planning Agent (`agent_type=plan`)
+
+When `agent_type=plan` is set alongside `system_prompt`, the custom prompt
+still replaces the built-in planning static prompt, but the planning tools
+and workflow instructions are kept — the agent keeps its ability to create
+and update plans, with your text as the foundation.
+
+## ACP Agents
+
+For ACP (Agent Communication Protocol) agents, which delegate to external
+CLIs (Claude Code, Gemini CLI, etc.), `system_prompt` is **ignored** with a
+server-side warning (`app_conversation_start:system_prompt_ignored_for_acp_agent`).
+ACP agents own their own system prompt and cannot be overridden via the REST
+API.
+
+## Feature Availability
+
+- OpenHands Cloud (currently deployed)
+- OpenHands Enterprise **1.62.0+**
+ ([enterprise#335](https://github.com/OpenHands/enterprise/pull/335))
+
+To verify on a specific deployment, look for `system_prompt` in the
+`AppConversationStartRequest` schema:
+
+```bash
+curl -s https://your-deployment/openapi.json \
+ | jq '.components.schemas["AppConversationStartRequest-Input"].properties.system_prompt'
+```
+
+## APIs Used
+
+| Endpoint | Method | Purpose |
+| ----------------------------------------- | ------ | --------------------------------------------- |
+| `/api/v1/app-conversations` | POST | Start the conversation (returns a start task) |
+| `/api/v1/app-conversations/start-tasks` | GET | Poll the start task for `app_conversation_id` |
+| `/api/v1/conversation/{id}/events/search` | GET | Read `SystemPromptEvent` to verify |
+| `/api/v1/app-conversations/{id}` | DELETE | Delete the conversation |
+| `/api/v1/sandboxes/{id}?sandbox_id={id}` | DELETE | Delete the sandbox |
+
+All calls use `Authorization: Bearer `.
+
+## Related
+
+
+
+ same App Conversation API pattern, showing how to attach and read back arbitrary metadata.
+
+
+
+ start a conversation with a plugin pre-loaded, using the same start-task polling flow.
+
+
+
+ configure which tools the agent has access to (different customisation axis).
+
+
+
+
+
+
diff --git a/cookbook/disabled-skills.mdx b/cookbook/disabled-skills.mdx
new file mode 100644
index 000000000..18763dc76
--- /dev/null
+++ b/cookbook/disabled-skills.mdx
@@ -0,0 +1,534 @@
+---
+title: Disabled Skills
+description: Persist an account-level deny-list of skills so every conversation starts with them disabled.
+icon: ban
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (disabled-skills/README.md). Edit the source, not this file. */}
+
+
+
+Keep specific OpenHands skills out of every conversation an account starts,
+using the Cloud **Settings API** — the same endpoint the UI's *Settings →
+Skills* page toggles. Once installed, the deny-list applies to every
+`POST /api/v1/app-conversations` call that account makes, without the
+caller having to pass anything on each request.
+
+`POST /api/v1/settings` with `{"disabled_skills": [...]}` persists an
+**account-level** deny-list. On every conversation start the App Server
+computes `effective_disabled_skills = member ∪ profile ∪ request` and drops
+matching skill names from the `` block that ships in the first
+`SystemPromptEvent`. A skill disabled at any level stays off.
+
+Feature shipped in OpenHands Enterprise **1.62.0**
+([enterprise#335](https://github.com/OpenHands/enterprise/pull/335)) and is
+also live on OpenHands Cloud.
+
+## When to Use This
+
+Prefer the **account-level** setting for anything policy-shaped:
+
+- The team should never see a certain skill (e.g. no external git
+ integrations on an internal build; no `docker` when your sandbox base
+ image can't run nested Docker).
+- The deny-list must be enforced consistently — you can't accidentally
+ start a conversation without it because you forgot the field.
+- You don't want callers of the API to have to remember to include
+ `disabled_skills` on every request.
+
+Reach for the **per-request** field (`disabled_skills` on
+`POST /api/v1/app-conversations`) only as an escape hatch:
+
+- One-off runs that need extra skills off, on top of the account default.
+- Ad-hoc experiments where you're trying deny-lists without touching the
+ persisted setting.
+
+## Precedence
+
+`effective_disabled_skills` (from
+`openhands/app_server/app_conversation/live_status_app_conversation_service.py`)
+takes the union of three sources, order-preserving, de-duplicated:
+
+| Level | Where it lives | How to set it |
+| ---------------------- | ------------------------------------------------------- | --------------------------------------------------------------------- |
+| Account (member) | `user.disabled_skills` | `POST /api/v1/settings` with `disabled_skills: [...]` — this tutorial |
+| Launched Agent Profile | `agent_context.disabled_skills` on the resolved profile | `POST /api/v1/settings/profiles/{name}` |
+| Per-request | `disabled_skills` on the start request | `POST /api/v1/app-conversations` with `disabled_skills: [...]` |
+
+A name absent from the discovered skill catalog is a **harmless no-op** —
+you can safely include speculative names without failing the request. Names
+are matched exactly (case-sensitive) against the `` values returned by
+`GET /api/v1/skills/search?q=` (a bare call with no `q` returns
+microagents mixed in; pass a prefix like `?q=github` to see the skill names
+that appear in the `` block).
+
+## ⚠ Caveat: Deny-First Means New OpenHands Skills Opt You In
+
+The current design is a **deny-list**, not an allow-list: anything that isn't
+explicitly disabled is loaded. That is deliberately drift-tolerant for most
+callers — a name you listed that no longer exists is a no-op, so your config
+survives rename/removal — but it has one consequence worth understanding
+before you ship this to production:
+
+
+ **When the OpenHands team adds a new built-in skill in a future SDK
+ release, every account whose `disabled_skills` doesn't name it will start
+ loading it on the next conversation, with no code change on your side.**
+
+
+For most teams that's the desired behaviour: you get new capabilities for
+free. But it's a problem when:
+
+- **Code (yours or an agent's) references skills by name.** A new skill
+ whose name collides with a trigger keyword or auto-injection rule you
+ depend on can start firing on turns it didn't fire on yesterday, changing
+ observable behaviour without a deploy on your side.
+- **The deny-list is policy, not preference.** If `disabled_skills` is
+ there because an auditor said "no external git integrations", the correct
+ guarantee is "no git integrations ever", not "no git integrations we knew
+ about at the time". A new `gerrit` or `codecommit` skill would quietly
+ bypass that intent.
+- **You need a stable per-turn context budget.** New skills (even if never
+ invoked) still show up in the `` block of the dynamic context and
+ consume tokens.
+
+### Lock-down pattern: snapshot the catalog, then deny everything else
+
+When you need tight control, treat the current skill catalog as the
+authoritative set and deny everything in it except the names you want to
+keep. This inverts the deny-list into an effective allow-list while still
+using the only mechanism the server exposes.
+
+The authoritative source for "what skills does the agent see by default"
+is the `` block of a `SystemPromptEvent` on a probe conversation
+started with **no** `disabled_skills` set. (`GET /api/v1/skills/search`
+without a `q` prefix returns microagents rather than the SDK skills that
+appear in ``, so parsing the event is the reliable path — the
+same path `disabled_skills.py` uses to verify absence.)
+
+```python
+import re
+import requests
+
+headers = {"Authorization": f"Bearer {api_key}"}
+_SKILLS_BLOCK = re.compile(r"(.*?)", re.DOTALL)
+_SKILL_NAME = re.compile(r"([^<]+)")
+
+# 1. Clear any existing deny-list so the probe sees the full catalog.
+requests.post(
+ f"{base_url}/api/v1/settings", headers=headers, json={"disabled_skills": []},
+)
+
+# 2. Start a probe conversation and poll start-tasks until READY (omitted
+# for brevity — see custom-system-prompt/custom_system_prompt.py).
+probe_conv_id = start_and_wait(base_url, headers)
+
+# 3. Read SystemPromptEvent and extract every in .
+event = requests.get(
+ f"{base_url}/api/v1/conversation/{probe_conv_id}/events/search",
+ headers=headers,
+ params={"kind__eq": "SystemPromptEvent", "limit": 1},
+).json()["items"][0]
+
+skills_block = _SKILLS_BLOCK.search(event["dynamic_context"]["text"]).group(1)
+all_skill_names = set(_SKILL_NAME.findall(skills_block))
+
+# 4. Decide the small set you *do* want.
+allowed = {"github", "linear", "uv"}
+
+# 5. Deny the complement.
+requests.post(
+ f"{base_url}/api/v1/settings",
+ headers=headers,
+ json={"disabled_skills": sorted(all_skill_names - allowed)},
+)
+```
+
+The probe is a one-time cost per catalog refresh; cache
+`all_skill_names` and reuse it until the next SDK upgrade.
+
+Operational notes for teams running this as policy:
+
+- **Re-snapshot on every SDK or platform upgrade.** A new build may ship
+ new skill names; your deny-list won't cover them until you refresh it.
+ Wire the snapshot step into your deploy pipeline (or run it on a
+ schedule) rather than treating the deny-list as set-and-forget.
+- **Diff before you apply.** `set(current) ^ set(snapshot)` tells you
+ exactly which skill names appeared or disappeared since the last run —
+ surface that in a code review or audit log so a human owns the decision
+ about any newcomer.
+- **The agent profile is the right place to persist a locked-down set.**
+ `POST /api/v1/settings/profiles/{name}` holds a `disabled_skills` list
+ that applies on top of the account-level one, so a `"locked-down"`
+ profile can carry the full complement without the account default
+ having to repeat it.
+
+An explicit allow-list field isn't currently exposed by the API; snapshot +
+deny-the-rest is the supported workaround.
+
+## How It Works
+
+### 1. Install the account-level deny-list
+
+```python
+requests.post(
+ f"{base_url}/api/v1/settings",
+ headers={"Authorization": f"Bearer {api_key}"},
+ json={"disabled_skills": ["github", "gitlab", "bitbucket"]},
+)
+```
+
+This is a partial settings save — the server deep-merges, so sending just
+this one field leaves every other setting untouched. It's the exact call
+the UI's *Settings → Skills* page makes via
+`useSkillMutations.saveDisabledSkills` in
+[`frontend/src/hooks/mutation/use-skill-mutations.ts`](https://github.com/OpenHands/enterprise/blob/main/frontend/src/hooks/mutation/use-skill-mutations.ts).
+
+One footgun: the server treats a `null` payload as "no change" (it copies
+the previously-persisted list forward). To clear the deny-list, send `[]`.
+`effective_disabled_skills` treats `None` and `[]` identically, so the
+observable behaviour is the same.
+
+### 2. Start a conversation with nothing on the request
+
+```python
+requests.post(
+ f"{base_url}/api/v1/app-conversations",
+ headers=headers,
+ json={
+ "initial_message": {
+ "role": "user",
+ "content": [{"type": "text", "text": "Say hello."}],
+ },
+ "title": "no per-request disabled_skills",
+ },
+)
+```
+
+No `disabled_skills` field is sent — the account-level setting is doing
+all the work.
+
+### 3. Verify from the `SystemPromptEvent`
+
+The `SystemPromptEvent` is the first event in every conversation. Its
+`dynamic_context.text` contains a `...` block listing
+every skill the agent can invoke:
+
+```xml
+
+The following skills are available. ...
+
+
+ github-resilience
+ ...
+
+ ...
+
+
+```
+
+The script parses out every `` and asserts none of the denied names
+are present:
+
+```python
+event = requests.get(
+ f"{base_url}/api/v1/conversation/{conv_id}/events/search",
+ headers=headers,
+ params={"kind__eq": "SystemPromptEvent", "limit": 1},
+).json()["items"][0]
+
+skills_block = re.search(
+ r"(.*?)",
+ event["dynamic_context"]["text"],
+ re.DOTALL,
+).group(1)
+loaded = re.findall(r"([^<]+)", skills_block)
+
+assert set(disabled_names).isdisjoint(loaded)
+```
+
+See the [`custom-system-prompt`](/cookbook/custom-system-prompt) tutorial for
+the full shape of `SystemPromptEvent`.
+
+### 4. Cleanup and restore
+
+```python
+requests.delete(f"{base_url}/api/v1/app-conversations/{conv_id}", headers=headers)
+requests.delete(
+ f"{base_url}/api/v1/sandboxes/{sandbox_id}",
+ headers=headers,
+ params={"sandbox_id": sandbox_id},
+)
+requests.post(
+ f"{base_url}/api/v1/settings",
+ headers=headers,
+ json={"disabled_skills": original or []},
+)
+```
+
+The script always restores the original account setting, even on error
+(via `try / finally`), so a failed run never leaves the account with a
+stuck deny-list.
+
+## Prerequisites
+
+```bash
+pip install requests
+export OH_API_KEY="your-api-key" # Cloud: Profile → API Keys.
+ # OHE: Settings → API Keys on your instance.
+```
+
+The account whose API key you use is the one whose `disabled_skills`
+setting the script mutates and then restores.
+
+## Run It
+
+```bash
+# default: 'no-git-integrations' gallery, account-level only
+python disabled_skills.py
+
+# pick a different gallery
+python disabled_skills.py --gallery no-docker
+python disabled_skills.py --gallery no-github-automations
+
+# explicit skill names (overrides --gallery)
+python disabled_skills.py --disable docker kubernetes pdflatex
+
+# also start a second conversation with additional per-request skills
+# to demonstrate account ∪ request:
+python disabled_skills.py --per-request
+
+# target an OpenHands Enterprise instance:
+python disabled_skills.py --base-url https://your-ohe.example.com
+
+# keep conversations for inspection (account setting is still restored):
+python disabled_skills.py --keep
+```
+
+The script:
+
+1. Reads and remembers the current account-level `disabled_skills`.
+2. Installs the requested deny-list via `POST /api/v1/settings`.
+3. Starts a conversation with **no** `disabled_skills` on the request,
+ proving the account-level setting alone keeps the skills out.
+4. Parses `` in `dynamic_context.text` and asserts every disabled
+ name is absent.
+5. Optionally repeats with per-request `disabled_skills` to show union.
+6. Deletes the conversation(s) and sandbox(es), and restores the original
+ account-level setting — the beta instance is left as it was found.
+
+Exit status is non-zero if any verification fails.
+
+### Real output
+
+Captured against `https://app.all-hands.dev` with `--per-request`:
+
+```text
+=== install account-level deny-list ===
+ disabling: ['github', 'gitlab', 'bitbucket', 'bitbucket-cloud', 'bitbucket-data-center', 'azure-devops']
+ previous account setting: []
+ new account setting: ['github', 'gitlab', 'bitbucket', 'bitbucket-cloud', 'bitbucket-data-center', 'azure-devops']
+
+=== conversation 1: no per-request disabled_skills ===
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: SETTING_UP_SKILLS
+ start-task status: SETTING_UP_SKILLS
+ start-task status: STARTING_CONVERSATION
+ start-task status: STARTING_CONVERSATION
+ start-task status: READY
+ conversation: 445d3864b33f4ad594522abddee5169b sandbox: 3sHSpWbMEFoSvEzl9g7NyN
+ account-only: loaded 103 skills
+ - github absent
+ - gitlab absent
+ - bitbucket absent
+ - bitbucket-cloud absent
+ - bitbucket-data-center absent
+ - azure-devops absent
+ PASS (account-only): all denied skills absent from SystemPromptEvent
+
+=== conversation 2: per-request disabled_skills = ['flarglebargle'] (union should be ['github', 'gitlab', 'bitbucket', 'bitbucket-cloud', 'bitbucket-data-center', 'azure-devops', 'flarglebargle']) ===
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: WAITING_FOR_SANDBOX
+ start-task status: SETTING_UP_SKILLS
+ start-task status: SETTING_UP_SKILLS
+ start-task status: STARTING_CONVERSATION
+ start-task status: READY
+ conversation: f73fc9ad970a4cb88f1030851f47f8a9 sandbox: 2V2xzlr0wGG6oRLLho59jf
+ account + request: loaded 102 skills
+ - github absent
+ - gitlab absent
+ - bitbucket absent
+ - bitbucket-cloud absent
+ - bitbucket-data-center absent
+ - azure-devops absent
+ - flarglebargle absent
+ PASS (account + request): all denied skills absent from SystemPromptEvent
+
+=== cleanup ===
+ deleted conversation 445d3864b33f4ad594522abddee5169b
+ deleted sandbox 3sHSpWbMEFoSvEzl9g7NyN
+ deleted conversation f73fc9ad970a4cb88f1030851f47f8a9
+ deleted sandbox 2V2xzlr0wGG6oRLLho59jf
+
+=== restore original account-level setting ===
+ restored account setting: []
+```
+
+Conversation 1 loaded 103 skills; conversation 2 loaded 102 (the extra
+`flarglebargle` denial removed one). Every denied name is absent from
+the `` block of the `SystemPromptEvent`.
+
+## Deny-List Gallery
+
+Common recipes, all exposed through `--gallery`:
+
+### `no-git-integrations`
+
+Drop every git-hosting integration skill. Useful for internal-only
+environments or when you're deliberately keeping the agent off customer
+repos.
+
+```python
+[
+ "github",
+ "gitlab",
+ "bitbucket",
+ "bitbucket-cloud",
+ "bitbucket-data-center",
+ "azure-devops",
+]
+```
+
+### `no-browser`
+
+Turn off skills whose primary value is browsing/preview flows. (The
+browser *tool* itself is off via the agent config, not this deny-list;
+see [`../custom-agent-no-browser/`](/cookbook/custom-agent-no-browser).)
+
+```python
+["frontend-design", "vercel"]
+```
+
+### `no-docker`
+
+Keep the agent out of Docker and Kubernetes skills — appropriate when
+your sandbox base image doesn't support nested Docker.
+
+```python
+["docker", "kubernetes"]
+```
+
+### `no-github-automations`
+
+Keep the general `github` skill available but hide the automation
+recipes (workflows, monitors, watchdogs) if they're not for this team.
+
+```python
+[
+ "github-actions",
+ "github-agents-md-maintainer",
+ "github-delivery-watchdog",
+ "github-issue-to-pr",
+ "github-issue-triage",
+ "github-pr-review",
+ "github-pr-reviewer",
+ "github-repo-monitor",
+ "github-stale-ci-pr-closer",
+]
+```
+
+Roll your own with `--disable name1 name2 name3`. Skill names come from
+`GET /api/v1/skills/search?q=`.
+
+## Per-Request Escape Hatch
+
+Set `disabled_skills` on the start request to *extend* the deny-list for
+a single conversation:
+
+```python
+requests.post(
+ f"{base_url}/api/v1/app-conversations",
+ headers=headers,
+ json={
+ "disabled_skills": ["flarglebargle"],
+ "initial_message": {
+ "role": "user",
+ "content": [{"type": "text", "text": "Say hello."}],
+ },
+ "title": "one-off deny-list bump",
+ },
+)
+```
+
+The three levels union — you can't *re-enable* an account- or
+profile-level denial by omitting it here. If the account has `[github]`
+and the request sends `[docker]`, the effective deny-list is
+`[github, docker]`.
+
+## Feature Availability
+
+- OpenHands Cloud (currently deployed)
+- OpenHands Enterprise **1.62.0+**
+ ([enterprise#335](https://github.com/OpenHands/enterprise/pull/335))
+
+To verify on a specific deployment, look for `disabled_skills` in both the
+`GETSettingsModel` and the `AppConversationStartRequest-Input` schemas:
+
+```bash
+curl -s https://your-deployment/openapi.json | jq '
+ .components.schemas.GETSettingsModel.properties.disabled_skills,
+ .components.schemas["AppConversationStartRequest-Input"].properties.disabled_skills
+'
+```
+
+To confirm the endpoint you're calling is the same one the UI uses,
+grep the enterprise frontend:
+
+```bash
+gh repo clone OpenHands/enterprise
+grep -rn "disabled_skills" enterprise/frontend/src/hooks/mutation/
+# -> use-skill-mutations.ts calls SettingsService.saveSettings({ disabled_skills })
+# -> settings-service.api.ts posts to /api/v1/settings
+```
+
+## APIs Used
+
+| Endpoint | Method | Purpose |
+| ----------------------------------------- | ------ | ---------------------------------------------------- |
+| `/api/v1/settings` | GET | Read current account `disabled_skills` (for restore) |
+| `/api/v1/settings` | POST | Install / clear the account-level deny-list |
+| `/api/v1/app-conversations` | POST | Start the conversation (`disabled_skills` optional) |
+| `/api/v1/app-conversations/start-tasks` | GET | Poll the start task for `app_conversation_id` |
+| `/api/v1/conversation/{id}/events/search` | GET | Read `SystemPromptEvent` to verify |
+| `/api/v1/app-conversations/{id}` | DELETE | Delete the conversation |
+| `/api/v1/sandboxes/{id}?sandbox_id={id}` | DELETE | Delete the sandbox |
+| `/api/v1/skills/search?q=` | GET | Discover valid skill names for the deny-list |
+
+All calls use `Authorization: Bearer `.
+
+## Related
+
+
+
+ sibling tutorial covering the other half of enterprise#335: replacing the static system prompt.
+
+
+
+ turn off the browser tool (a different customisation axis; deny-list here operates on skills).
+
+
+
+ start a conversation with a plugin pre-loaded, using the same start-task polling flow.
+
+
+
+
+
+
diff --git a/cookbook/finish-callback.mdx b/cookbook/finish-callback.mdx
new file mode 100644
index 000000000..3a6e9d502
--- /dev/null
+++ b/cookbook/finish-callback.mdx
@@ -0,0 +1,289 @@
+---
+title: Finish Callback
+description: Notify an external URL when a conversation finishes using a Stop hook.
+icon: bell
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (finish-callback/README.md). Edit the source, not this file. */}
+
+
+
+A self-contained example showing how to **notify an external URL the moment a
+conversation finishes**, using a **Stop hook** — instead of finding out only by
+polling. When the agent reaches a terminal state, the hook `POST`s to a URL you
+control, optionally attaching a payload file's contents.
+
+This is the "push instead of poll" pattern: keep your existing polling loop as a
+safety net, but let the callback wake you up immediately in the common case.
+
+[](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAiZmluaXNoLWNhbGxiYWNrL29oLWZpbmlzaC1jYWxsYmFjayJ9XQ%3D%3D\&message=Say%20hello%20and%20then%20finish.)
+
+
+ **About the badge:** clicking it loads the plugin into a fresh conversation,
+ but the `/launch` route only carries `plugins` + `message` — **not secrets**.
+ Without `OH_CALLBACK_URL` the hook is a deliberate no-op, so the badge is a
+ "see the plugin load" demo. To actually receive a callback you must supply the
+ secrets, which means the API path below (`load_finish_callback.py`) or your own
+ call to `POST /api/v1/app-conversations` with a `secrets` field.
+
+
+
+ **No customer information lives in this plugin.** The callback URL, an optional
+ shared-secret token, and an optional payload file path all come from
+ **conversation secrets** at start time. The repo ships only a local test
+ receiver so you can prove the end-to-end flow against your own machine.
+
+
+## What's in the Box
+
+```text
+finish-callback/
+├── callback_receiver.py # tiny stdlib web server (the demo receiver)
+├── load_finish_callback.py # turnkey: start a conversation + pass the secrets
+├── example-payload.json # optional POST body you can point the hook at
+└── oh-finish-callback/ # the plugin (load this into a conversation)
+ ├── .claude-plugin/
+ │ └── plugin.json # plugin manifest
+ ├── hooks/
+ │ ├── hooks.json # the Stop hook (this is what actually runs)
+ │ └── on_stop.sh # readable source of the inline hook script
+ └── skills/
+ └── oh-finish-callback/
+ └── SKILL.md # docs (auto-loaded)
+```
+
+## How It Works
+
+```mermaid
+flowchart TD
+ A["Agent finishes its work → conversation reaches FINISHED"] --> B["Stop hook fires (before the stop is finalized)"]
+ B --> B1["reads OH_CALLBACK_URL / OH_CALLBACK_TOKEN / OH_CALLBACK_PAYLOAD from env"]
+ B1 --> B2["builds the body: your payload file, or a small default JSON envelope"]
+ B2 --> B3["POSTs to your URL (async, best-effort, short timeout)"]
+ B3 --> C["Agent stops normally (the hook always exits 0 — it never blocks)"]
+ C --> D["Your receiver gets the POST and reacts immediately"]
+```
+
+## Configuration
+
+Everything is supplied as **conversation secrets** — nothing is hard-coded:
+
+| Variable | Required | Meaning |
+| --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `OH_CALLBACK_URL` | Yes | Where to `POST`. If unset, the hook is a harmless no-op. |
+| `OH_CALLBACK_TOKEN` | No | Sent as the `X-Callback-Token` header so your receiver can verify the caller. |
+| `OH_CALLBACK_PAYLOAD` | No | Path (inside the sandbox) to a JSON file whose raw contents become the POST body. Omit it to send the default envelope. |
+
+Default body when no payload file is given:
+
+```json
+{"status": "finished", "session_id": "", "finished_at": ""}
+```
+
+## Run It
+
+You need two things reachable from the sandbox: a **running receiver** and a
+**public URL** that forwards to it. The receiver is stdlib-only; the loader
+needs `requests`:
+
+```bash
+pip install requests
+```
+
+You'll also need a Cloud API key (`export OH_API_KEY="sk-oh-..."`).
+
+### 1. Start the receiver
+
+```bash
+cd finish-callback
+python callback_receiver.py --port 8000
+# add --token s3cr3t to require the X-Callback-Token header
+```
+
+It prints every POST it receives (pretty-printed JSON) and replies `204`.
+
+### 2. Expose it to the internet
+
+The sandbox runs in the cloud, so it needs a public URL to reach your laptop.
+Use any tunnel, e.g.:
+
+```bash
+# examples — pick whichever you have
+cloudflared tunnel --url http://localhost:8000
+ngrok http 8000
+```
+
+Note the public `https://…` URL it gives you. That's your `OH_CALLBACK_URL`.
+
+### 3. Start a conversation with the plugin loaded
+
+Use the bundled turnkey helper. It loads the plugin **and** passes the callback
+settings as conversation secrets, so the Stop hook picks them up as environment
+variables:
+
+```bash
+cd finish-callback
+export OH_API_KEY="sk-oh-..." # Cloud API key
+python load_finish_callback.py \
+ --callback-url "https://your-tunnel.example/oh_finish" \
+ --callback-token "s3cr3t"
+
+# Expected: when the agent finishes, your receiver prints a POST like:
+# {"status": "finished", "session_id": "...", "finished_at": "..."}
+```
+
+To send your own body instead of the default envelope, add
+`--callback-payload "/path/in/sandbox/body.json"` (the path is resolved inside
+the sandbox, not on your laptop).
+
+**Prefer the generic loader?** [`load-plugin`](/cookbook/load-plugin) does the same
+thing with `--secret` flags:
+
+```bash
+cd ../load-plugin
+python load_plugin.py \
+ --repo-path finish-callback/oh-finish-callback \
+ --message "Say hello and then finish." \
+ --secret OH_CALLBACK_URL="https://your-tunnel.example/oh_finish" \
+ --secret OH_CALLBACK_TOKEN="s3cr3t"
+```
+
+
+ **Heads-up:** the callback fires on **every** transition to `FINISHED` — so it
+ also covers follow-up messages you send later, not just the first run.
+
+
+## Verify the Hook Locally (No Sandbox Needed)
+
+You can exercise the exact hook script against a local receiver in one shell:
+
+```bash
+cd finish-callback
+python callback_receiver.py --port 8000 & # start receiver
+
+echo '{"event_type":"Stop"}' \
+ | OPENHANDS_SESSION_ID="local-test" \
+ OH_CALLBACK_URL="http://127.0.0.1:8000/oh_finish" \
+ sh oh-finish-callback/hooks/on_stop.sh # run the hook
+
+# The receiver prints the POST it just got.
+```
+
+## The Hook
+
+The magic is in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/main/finish-callback/oh-finish-callback/hooks/hooks.json):
+
+```json
+{
+ "hooks": {
+ "Stop": [
+ {
+ "matcher": "*",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "cat >/dev/null; ... curl -s -m 10 -X POST \"$OH_CALLBACK_URL\" ...; exit 0",
+ "timeout": 15,
+ "async": true
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+**How it works:**
+
+1. **`Stop`** — runs when the agent tries to finish (the terminal-state moment).
+2. **`matcher: "*"`** — Stop hooks aren't tool-specific, so match everything.
+3. **`type: "command"`** — the `command` is a POSIX-sh script run via `/bin/sh -c`.
+4. **`async: true`** — fire-and-forget, so the callback never delays finishing.
+5. **Exit codes:** `0` = allow the agent to finish (this hook always does);
+ `2` would *block* finishing (we deliberately never do that).
+
+The script reads its config from the environment, builds the body, and `curl`s
+your URL with a short timeout, swallowing errors.
+
+
+ **Why inline (not a reference to the bundled `on_stop.sh`)?** When hooks run as
+ a **plugin**, they execute with the working directory set to the agent's
+ workspace (not the plugin directory), and there is no plugin-root path
+ variable — so a relative path like `hooks/on_stop.sh` won't resolve.
+ [`on_stop.sh`](https://github.com/OpenHands/enterprise-cookbook/blob/main/finish-callback/oh-finish-callback/hooks/on_stop.sh) is kept as the readable,
+ locally-testable **source of truth**; the identical script is embedded inline
+ in `hooks.json`, which is the copy that actually runs. If you edit the script,
+ re-embed it:
+
+ ```bash
+ python - <<'PY'
+ import json
+ s = open("oh-finish-callback/hooks/on_stop.sh").read()
+ cfg = {"hooks": {"Stop": [{"matcher": "*", "hooks": [
+ {"type": "command", "command": s, "timeout": 15, "async": True}]}]}}
+ json.dump(cfg, open("oh-finish-callback/hooks/hooks.json", "w"), indent=2)
+ open("oh-finish-callback/hooks/hooks.json", "a").write("\n")
+ PY
+ ```
+
+
+## Reliability: Callback + Polling
+
+The callback is a **latency optimization, not a delivery guarantee**. It won't
+fire if:
+
+- the sandbox dies or the run errors out before reaching `FINISHED`,
+- the receiver is down or the URL is unreachable, or
+- the POST times out.
+
+So keep your polling loop as the **safety net** and treat the callback as the
+fast path. That's exactly the hybrid the pattern is designed for: react
+immediately when the callback arrives, fall back to polling when it doesn't.
+
+## Hook Types
+
+Hooks can intercept different lifecycle events:
+
+| Hook | When It Runs | Can Block? | Use Case |
+| ---------------- | ---------------------------------- | -------------- | -------------------------------------- |
+| PreToolUse | Before tool execution | ✅ Yes (exit 2) | Command validation |
+| PostToolUse | After tool execution | ❌ No | Logging, metrics |
+| UserPromptSubmit | Before processing user message | ✅ Yes | Content filtering |
+| **Stop** | **When the agent tries to finish** | ✅ Yes | **Finish notification (this example)** |
+| SessionStart | When a conversation starts | ❌ No | Setup, logging |
+| SessionEnd | When a conversation ends | ❌ No | Cleanup |
+
+## Real-World Use Cases
+
+- **Windmill / workflow engines** — get pinged when a run finishes instead of polling every few seconds
+- **CI pipelines** — kick off the next stage the moment the agent is done
+- **Dashboards / queues** — mark a job complete in real time
+- **Chat notifications** — post "run finished" to Slack/Teams from your own backend
+
+## Related
+
+
+
+ full hook documentation
+
+
+
+ how plugins work
+
+
+
+ load this plugin (and pass secrets) via the REST API
+
+
+
+ the PreToolUse example this one is modeled on
+
+
+
+ turn a plugin into a no-code launch link
+
+
+
+ attach metadata (like an external URL) to a conversation
+
+
diff --git a/cookbook/gpg-commit-signing.mdx b/cookbook/gpg-commit-signing.mdx
new file mode 100644
index 000000000..9098d5d81
--- /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@main (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/main/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/main/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/main/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`](/cookbook/workspace-isolation) example.)
+
+## Try it
+
+### Option 1: Load via API
+
+Use the companion [`load-plugin`](/cookbook/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`](/cookbook/command-whitelist), [`workspace-isolation`](/cookbook/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`](/cookbook/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`](/cookbook/command-whitelist) / [`workspace-isolation`](/cookbook/workspace-isolation) — other hook examples
diff --git a/cookbook/index.mdx b/cookbook/index.mdx
index 7a062399b..905037cd4 100644
--- a/cookbook/index.mdx
+++ b/cookbook/index.mdx
@@ -10,14 +10,118 @@ 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.
-
+
+ Retrieve cost and token usage metrics for conversations via CLI tool.
+
+
+
Attach key-value metadata to a conversation with tags and read it back from AppConversation.tags.
+
+
+ Notify an external URL when a conversation finishes using a Stop hook.
+
+
+
+ React to conversation execution_status changes over WebSocket instead of polling.
+
+
+
+ Poll the idle_time endpoint to detect when a workspace has gone quiet.
+
+
+
+ Detect confirmed terminal execution_status over WebSocket with proper event handling.
+
+
+
+## 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.
+
+
+
+## Plugins, skills & MCP
+
+Extend conversations with plugins, skills, and MCP servers.
+
+
+
+ Build a no-code /launch link, HTML button, or README badge that loads a plugin.
+
+
+
+ Start a conversation with a plugin pre-loaded via the REST API.
+
+
+
+ Validate MCP server configs against a sandbox agent-server via POST /api/mcp/test.
+
+
+
+ Upload a local agent-skills directory into a sandbox, then start a conversation that uses them.
+
+
+
+## Custom agents & tools
+
+Configure the agent and add custom tools.
+
+
+
+ Configure agent tools via the agent-server API to exclude the browser tool.
+
+
+
+ Load a custom tool from a published pip package via pip install --target and tool_module_qualnames.
+
+
+
+ Add custom server-side tools via source file upload and tool_module_qualnames.
+
+
+
+ Override the default OpenHands system prompt with a custom one for specialized agents.
+
+
+
+ Persist an account-level deny-list of skills so every conversation starts with them disabled.
+
## Guardrails
@@ -25,7 +129,15 @@ Observe conversations and react to their state.
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.
+
+
+ Allow only approved shell commands with PreToolUse hooks (whitelist approach for strict security).
+
+
+
+ Enforce directory boundaries with hooks to prevent agents from navigating or writing outside assigned workspace.
+
diff --git a/cookbook/launch-plugin-badge.mdx b/cookbook/launch-plugin-badge.mdx
new file mode 100644
index 000000000..f8a72d1b1
--- /dev/null
+++ b/cookbook/launch-plugin-badge.mdx
@@ -0,0 +1,252 @@
+---
+title: Launch Plugin Badge
+description: Build a no-code /launch link, HTML button, or README badge that loads a plugin.
+icon: rocket
+---
+
+{/* GENERATED from OpenHands/enterprise-cookbook@main (launch-plugin-badge/README.md). Edit the source, not this file. */}
+
+
+
+This example builds on [`load-plugin`](/cookbook/load-plugin). That one calls the API
+with your key to start a conversation with a plugin loaded. Here we make a
+**no-code launch link** anyone can click — perfect for an HTML `