> ## Documentation Index
> Fetch the complete documentation index at: https://docs.openhands.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Per-Conversation Secrets

> Inject per-conversation secrets via REST API as bash env vars and to template an MCP server config bundled in a plugin.

<Card title="View source on GitHub" icon="github" href="https://github.com/OpenHands/enterprise-cookbook/tree/main/per-conversation-secrets" horizontal />

This example demonstrates how to inject per-conversation secrets into an OpenHands
conversation using only REST APIs (no WebSocket required), and — importantly — how
those secrets can be **expanded into a plugin's MCP server configuration** so the
agent can talk to a third-party MCP server with a token that lives only for the
lifetime of one conversation.

## Why you'd want this

OpenHands already lets users define secrets at the *user* level (stored in the
vault). That's fine for stable, long-lived credentials. But there are cases where
you want a secret that is:

* **Scoped to a single conversation** — e.g., a customer's OAuth token, a
  temporary CI credential, or a one-off API key the calling system minted for
  this run.
* **Used to authenticate an MCP server**, not just exported as a bash variable —
  e.g., wire the agent into a customer's Linear / GitHub / internal API by
  passing the right `Authorization: Bearer …` header on every MCP call.

This example shows both, and how they compose.

## The two patterns shown here

A single `secrets` field on the conversation-start request powers two related but
distinct patterns. **Read this section before anything else** — most of the
confusion in earlier versions of this README came from blurring them together.

### Pattern A — Secrets as bash environment variables

You pass `{"MY_KEY": "value"}` at conversation start (or inject it later via the
agent server's `/secrets` endpoint), and `$MY_KEY` becomes available to every
`bash` command the agent runs.

* **Demonstrated by:** `test_secrets_at_start.py` (recommended) and
  `test_secrets.py` (legacy / mid-conversation injection).
* **Use when:** the agent needs a credential to run a CLI command, hit an HTTP
  API via `curl`, or otherwise consume a secret from the shell.

### Pattern B — Secrets as `${VARIABLE}` placeholders inside a plugin's MCP config

The same `secrets` field also feeds variable expansion in an OpenHands
**plugin**'s `.mcp.json`. A plugin is a small bundle of files (described below)
that OpenHands fetches from GitHub at conversation start; if its `.mcp.json`
contains `${MCP_SERVER_URL}` or `${MCP_SECRET_TOKEN}`, those placeholders are
filled in from the conversation's secrets *before* the MCP transport is dialed.

* **Demonstrated by:** `test_mcp_secrets_at_start.py` (recommended) and
  `test_mcp_secrets.py` (legacy).
* **Use when:** you want the agent to talk to an MCP server (yours or a
  customer's) using a token that isn't in the user's vault — for example, a
  per-tenant token chosen by your application for this conversation only.

The rest of this document walks through Pattern B in detail because it's the
less obvious of the two. Pattern A is just "set an env var" — see the test
scripts for end-to-end examples.

## What is a plugin (in this example)?

A plugin is a directory in a git repo with three files. The whole `test-plugin/`
folder in this repo is a working example:

```text theme={null}
test-plugin/
├── .mcp.json            # MCP server registration WITH ${...} placeholders
├── .plugin/plugin.json  # manifest: name, version, author
└── SKILL.md             # human-readable doc the agent reads
```

**`test-plugin/.mcp.json`** — note the `${VARIABLE}` placeholders:

```json theme={null}
{
  "mcpServers": {
    "token-validator": {
      "url": "${MCP_SERVER_URL}/mcp",
      "transport": "sse",
      "headers": {
        "Authorization": "Bearer ${MCP_SECRET_TOKEN}"
      }
    }
  }
}
```

**`test-plugin/.plugin/plugin.json`** — minimal manifest:

```json theme={null}
{
  "name": "secret-token-validator",
  "version": "1.0.0",
  "description": "Plugin that connects to an MCP server using a per-conversation secret token",
  "author": { "name": "OpenHands", "email": "openhands@all-hands.dev" },
  "license": "MIT"
}
```

**`test-plugin/SKILL.md`** — short markdown explaining the plugin's tools and
required secrets. The agent reads this so it knows what the plugin exposes.

You point a conversation at this plugin by adding a `plugins` entry alongside
`secrets` in the start request:

```python theme={null}
"plugins": [{
    "source": "github:OpenHands/enterprise-cookbook",
    "repo_path": "per-conversation-secrets/test-plugin"
}]
```

`source` resolves to a GitHub repo, `repo_path` is the directory within it.
OpenHands fetches the directory, reads `.mcp.json`, and **expands `${...}`
references against the `secrets` you passed in the same request** before
establishing the MCP transport. That expansion step is the whole point of
Pattern B.

## APIs used

These tests exercise **two separate OpenHands APIs**:

### 1. App Server API

* **Purpose:** Manages sandboxes, conversations, and user resources.
* **Base URL:** `https://app.all-hands.dev/api` (or your deployment).
* **Auth header:** `X-Access-Token: <your_api_key>`
* **OpenAPI spec:** `https://app.all-hands.dev/openapi.json`

### 2. Agent Server API

* **Purpose:** Direct agent interaction inside a running sandbox.
* **Base URL:** From the sandbox's `exposed_urls` array (entry with
  `name="AGENT_SERVER"`).
* **Auth header:** `X-Session-API-Key: <session_api_key>` (from sandbox creation).
* **OpenAPI spec:** `{agent_server_url}/openapi.json`

> **Tip:** To explore the Agent Server API, first create a sandbox via the App
> Server, wait for it to reach `RUNNING` status, then fetch
> `{agent_server_url}/openapi.json`.

## Two ways to deliver the secrets

Independent of A vs. B above, there are two *timings* for getting secrets into
the conversation:

### 1. At conversation start (recommended)

Pass secrets directly in the `POST /v1/app-conversations` request body:

```python theme={null}
requests.post(
    f'{api_url}/v1/app-conversations',
    headers={'X-Access-Token': api_key},
    json={
        'sandbox_id': sandbox_id,
        'initial_message': {...},
        'secrets': {
            'GITHUB_TOKEN': 'ghp_...',
            'MCP_SECRET_TOKEN': 'per-conv-secret-xyz-123',
            'MCP_SERVER_URL': 'https://...',
        },
        'plugins': [{                  # only needed for Pattern B
            'source': 'github:OpenHands/enterprise-cookbook',
            'repo_path': 'per-conversation-secrets/test-plugin',
        }],
    },
)
```

**Advantages:**

* Single request — simpler API.
* Secrets available immediately when the agent starts.
* No race condition — guaranteed to be set before the agent runs.
* Secrets are merged with vault secrets (request secrets take precedence).
* **Required for Pattern B** — `${...}` expansion in a plugin's `.mcp.json`
  needs the secrets to be present *before* the MCP transport is opened.

**Requirements:**

* OpenHands PR [#14009](https://github.com/OpenHands/OpenHands/pull/14009)
* SDK PR [#2873](https://github.com/OpenHands/software-agent-sdk/pull/2873)

### 2. After conversation start (original)

Inject secrets via the Agent Server's `/secrets` endpoint after the conversation
already exists:

```python theme={null}
requests.post(
    f'{agent_server_url}/api/conversations/{conv_id}/secrets',
    headers={'X-Session-API-Key': session_api_key},
    json={'secrets': {'MY_SECRET': 'value'}},
)
```

**Use when:**

* You need to add secrets mid-conversation.
* You're on an older OpenHands version without the at-start `secrets` field.

Note: post-hoc injection works for Pattern A (bash env vars) but **does not help
with Pattern B**, because the MCP transport for a plugin is established when the
conversation starts.

## Quick comparison

| Feature | At start (new) | After start (original) |
| - | - | - |
| API | App Server | Agent Server |
| Endpoint | `POST /v1/app-conversations` | `POST /api/conversations/{id}/secrets` |
| Auth header | `X-Access-Token: {api_key}` | `X-Session-API-Key: {session_key}` |
| Timing | Before agent runs | After conversation created |
| Simplicity | Single request | Multiple requests |
| Mid-conversation updates | No | Yes |
| Works for Pattern A | Yes | Yes |
| Works for Pattern B | **Yes** | No |

## Architecture

```text theme={null}
┌─────────────────────┐   ┌──────────────────────┐   ┌─────────────────────┐
│  App Server         │   │   Agent Server       │   │   MCP Server        │
│  app.all-hands.dev  │   │   (per-sandbox URL)  │   │   (validates token) │
├─────────────────────┤   ├──────────────────────┤   ├─────────────────────┤
│ POST /v1/sandboxes  │──▶│                      │   │                     │
│ POST /v1/app-conv   │   │                      │   │                     │
│   • secrets         │   │  Plugin loader       │   │                     │
│   • plugins ────────┼──▶│  ┌────────────────┐  │   │                     │
│                     │   │  │ fetch from GH  │  │   │                     │
│                     │   │  │ read .mcp.json │  │   │                     │
│                     │   │  │ expand ${...}  │  │   │                     │
│                     │   │  │   ↑ from       │  │   │                     │
│                     │   │  │   secrets      │  │   │                     │
│                     │   │  └───────┬────────┘  │   │                     │
│                     │   │          │           │   │                     │
│                     │   │          ▼           │   │                     │
│                     │   │  MCP transport ──────┼──▶│ validate_token()    │
│                     │   │  (Authorization:     │   │                     │
│                     │   │   Bearer <secret>)   │   │                     │
│                     │   │                      │   │                     │
│                     │   │  POST /secrets ──── (Pattern A, after-start)   │
│                     │   │  POST /events        │   │                     │
└─────────────────────┘   └──────────────────────┘   └─────────────────────┘
        │                         │                          │
        │ X-Access-Token:         │ X-Session-API-Key:       │ Authorization:
        │ {api_key}               │ {session_api_key}        │ Bearer ${MCP_SECRET_TOKEN}
```

## Key findings

1. **Two different conversation IDs.** The App Server and the Agent Server use
   different IDs for the same conversation. You must query the Agent Server to
   find the correct ID for its endpoints.
2. **Two authentication schemes.**
   * App Server: `X-Access-Token: {api_key}`
   * Agent Server: `X-Session-API-Key: {session_api_key}`
3. **Secrets endpoint (Agent Server):** `POST /api/conversations/{id}/secrets`
   with body `{"secrets": {"KEY": "value"}}`. Secrets become environment
   variables (`$KEY`) for bash commands.
4. **`${...}` expansion only works when secrets are passed at start.** A plugin
   loaded via `plugins: [...]` has its `.mcp.json` expanded against the
   `secrets` field of the same `POST /v1/app-conversations` request. Post-hoc
   injection via the Agent Server's `/secrets` endpoint is too late to influence
   MCP transport setup.

## Files

| File | Purpose |
| - | - |
| `test_secrets_at_start.py` | **Pattern A**, at-start: secrets as bash env vars, passed in the start request. |
| `test_secrets.py` | **Pattern A**, after-start: secrets injected via Agent Server `/secrets`. |
| `test_mcp_secrets_at_start.py` | **Pattern B**, at-start: secrets + plugin in one request, MCP config expanded from them. |
| `test_mcp_secrets.py` | **Pattern B**, after-start: legacy variant. Kept for reference. |
| `test-plugin/.mcp.json` | Plugin's MCP server registration with `${MCP_SERVER_URL}` / `${MCP_SECRET_TOKEN}`. |
| `test-plugin/.plugin/plugin.json` | Plugin manifest (name, version, author, license). |
| `test-plugin/SKILL.md` | Human-readable doc the agent reads to understand the plugin. |
| `mcp_server.py` | Stand-alone MCP server that validates the expected `Authorization: Bearer …` token. |

## Usage

### Pattern A — secrets at conversation start (recommended)

```bash theme={null}
export OH_API_KEY="sk-oh-..."

# Optional: staging / feature deployment
# export OH_API_URL="https://ohpr-14009-30.staging.all-hands.dev/api"

# Optional: reuse an existing RUNNING sandbox (avoids cold start)
# export OH_SANDBOX_ID="your-sandbox-id"

python test_secrets_at_start.py
```

Expected output ends with:

```text theme={null}
======================================================================
 ✅ SUCCESS! Secrets field was accepted in AppConversationStartRequest!
======================================================================
```

### Pattern A — secrets after conversation start (legacy)

```bash theme={null}
export OH_API_KEY="sk-oh-..."
python test_secrets.py
```

### Pattern B — secrets + plugin at conversation start (recommended)

This test verifies that secrets passed at conversation start are available for
MCP config variable expansion inside the plugin's `.mcp.json`.

```bash theme={null}
# Terminal 1: start the MCP server
python mcp_server.py --port 12000 --expected-token "per-conv-secret-xyz-123"

# Terminal 2: run the test
export OH_API_KEY="sk-oh-..."
export OH_API_URL="https://ohpr-14009-30.staging.all-hands.dev/api"   # or your deployment
export MCP_SERVER_URL="https://work-1-xxx.prod-runtime.all-hands.dev" # where mcp_server.py is reachable

python test_mcp_secrets_at_start.py
```

Expected output ends with:

```text theme={null}
======================================================================
  ✅ SUCCESS! Per-conversation secret was injected and used!
======================================================================
```

What happened end-to-end:

1. The test started a sandbox and called `POST /v1/app-conversations` with
   `secrets={"MCP_SERVER_URL": ..., "MCP_SECRET_TOKEN": "per-conv-secret-xyz-123"}`
   *and* `plugins=[{source: github:OpenHands/enterprise-cookbook, repo_path: per-conversation-secrets/test-plugin}]`.
2. OpenHands fetched `test-plugin/` from GitHub, read `.mcp.json`, and
   substituted both `${MCP_SERVER_URL}` and `${MCP_SECRET_TOKEN}` from the
   secrets above.
3. The agent dialed the resulting URL with header
   `Authorization: Bearer per-conv-secret-xyz-123`.
4. `mcp_server.py` compared the token to its `--expected-token`, matched, and
   returned a success result that the test then asserts on.

## API workflow

```python theme={null}
# ============================================================
# APP SERVER API (https://app.all-hands.dev/api)
# Auth: X-Access-Token header
# ============================================================

# 1. Create sandbox
POST /v1/sandboxes
Headers: X-Access-Token: {api_key}
→ {id, session_api_key, status: "STARTING", exposed_urls: null}

# 2. Poll for RUNNING status
GET /v1/sandboxes/search
Headers: X-Access-Token: {api_key}
→ {items: [{id, status: "RUNNING", exposed_urls: [...], session_api_key}]}
# Find AGENT_SERVER in exposed_urls

# 3. Start conversation WITH secrets (and optionally a plugin)
POST /v1/app-conversations
Headers: X-Access-Token: {api_key}
Body: {
  sandbox_id: "...",
  initial_message: {...},
  secrets: {...},
  plugins: [{source: "github:...", repo_path: "..."}]   # for Pattern B
}

# ============================================================
# AGENT SERVER API (from exposed_urls AGENT_SERVER)
# Auth: X-Session-API-Key header
# ============================================================

# 4. Find conversation on agent server
GET /api/conversations/search
Headers: X-Session-API-Key: {session_api_key}
→ {items: [{id, status}]}

# 5. Send message / check events
POST /api/conversations/{id}/events
GET  /api/conversations/{id}/events/search
Headers: X-Session-API-Key: {session_api_key}
```

## API reference

### App Server API

| Endpoint | Method | Description |
| - | - | - |
| `/v1/sandboxes` | POST | Create sandbox → `{id, session_api_key, ...}` |
| `/v1/sandboxes/search` | GET | List sandboxes → `{items: [...]}` |
| `/v1/sandboxes/{id}` | DELETE | Delete sandbox (use `?sandbox_id=` query param) |
| `/v1/app-conversations` | POST | Start conversation (supports `secrets` and `plugins` fields) |

### Agent Server API

| Endpoint | Method | Description |
| - | - | - |
| `/api/conversations/search` | GET | List conversations → `{items: [...]}` |
| `/api/conversations/{id}/secrets` | POST | Inject secrets (Pattern A, after start) |
| `/api/conversations/{id}/events` | POST | Send user message |
| `/api/conversations/{id}/events/search` | GET | List events → `{items: [...]}` |

### Getting the OpenAPI specs

```bash theme={null}
# App Server OpenAPI
curl https://app.all-hands.dev/openapi.json

# Agent Server OpenAPI (requires a running sandbox)
# 1. Create sandbox and wait for RUNNING status
# 2. Get agent_server_url from exposed_urls (name="AGENT_SERVER")
curl {agent_server_url}/openapi.json
```

## Related

<CardGroup cols={2}>
  <Card title="service-account-github-pat" href="/cookbook/service-account-github-pat" icon="id-card">
    Use OpenHands as a service account with per-user PATs
  </Card>

  <Card title="gpg-commit-signing" href="/cookbook/gpg-commit-signing" icon="file-signature">
    Configure GPG signing with SessionStart hooks
  </Card>

  <Card title="OpenHands API Reference" href="https://app.all-hands.dev/docs" icon="arrow-up-right-from-square">
    Full API documentation
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.