Skip to main content

View source on GitHub

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:
test-plugin/.mcp.json — note the ${VARIABLE} placeholders:
test-plugin/.plugin/plugin.json — minimal manifest:
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:
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: Pass secrets directly in the POST /v1/app-conversations request body:
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:

2. After conversation start (original)

Inject secrets via the Agent Server’s /secrets endpoint after the conversation already exists:
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

Architecture

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

Usage

Expected output ends with:

Pattern A — secrets after conversation start (legacy)

This test verifies that secrets passed at conversation start are available for MCP config variable expansion inside the plugin’s .mcp.json.
Expected output ends with:
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

API reference

App Server API

Agent Server API

Getting the OpenAPI specs

service-account-github-pat

Use OpenHands as a service account with per-user PATs

gpg-commit-signing

Configure GPG signing with SessionStart hooks

OpenHands API Reference

Full API documentation