Skip to main content

View source on GitHub

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) 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: 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 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): The caching split is wired in llm/llm.py::_apply_prompt_caching — 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; dynamic-tier sections in context/prompts/sections/dynamic.py. The default composition order is pinned in context/prompts/presets.py: Static block, in order — <SOUL> and <ROLE> (identity), <MEMORY> (AGENTS.md or persistent-memory guidance), <EFFICIENCY>, <FILE_SYSTEM_GUIDELINES>, <CODE_QUALITY>, <VERSION_CONTROL>, <PULL_REQUESTS>, <PROBLEM_SOLVING_WORKFLOW>, <SELF_DOCUMENTATION> (work habits), <SECURITY> and <SECURITY_RISK_ASSESSMENT> (safety policy), <BROWSER_TOOLS> (only if enable_browser), <EXTERNAL_SERVICES>, <ENVIRONMENT_SETUP>, <TROUBLESHOOTING>, <PROCESS_MANAGEMENT>, and <IMPORTANT> (per-model-family tweaks for Claude, Gemini, GPT-5). Dynamic block, in order — <REPO_CONTEXT>, <MEMORY_CONTEXT>, <SKILLS>, your system_message_suffix (raw, no wrapper), <CUSTOM_SECRETS>, and <CURRENT_DATETIME> 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 <CURRENT_DATETIME> to dynamic_context — reference it from the static text (e.g. “see <CURRENT_DATETIME> 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: 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.

2. Wait for app_conversation_id

Poll /api/v1/app-conversations/start-tasks?ids=<task_id> 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.

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.
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.

Prerequisites

You do not need to set LLM credentials — the App Server uses whatever LLM your account is configured with.

Run It

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:
Swap --prompt (or SYSTEM_PROMPT) for any of these to try them out.

Research assistant

Code reviewer

Documentation writer

Data analyst

Composition with system_message_suffix

system_message_suffix is a separate field appended to the dynamic context. You can use both together:
The resulting prompt structure is:

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)
To verify on a specific deployment, look for system_prompt in the AppConversationStartRequest schema:

APIs Used

All calls use Authorization: Bearer <OH_API_KEY>.

conversation-tags

same App Conversation API pattern, showing how to attach and read back arbitrary metadata.

load-plugin

start a conversation with a plugin pre-loaded, using the same start-task polling flow.

custom-agent-no-browser

configure which tools the agent has access to (different customisation axis).

OpenHands SDK — Agent Settings

OpenHands Enterprise PR #335