Skip to main content

View source on GitHub

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 <SKILLS> 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) 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: 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 <name> values returned by GET /api/v1/skills/search?q=<prefix> (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 <SKILLS> 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 <SKILLS> 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 <SKILLS> 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 <SKILLS>, so parsing the event is the reliable path — the same path disabled_skills.py uses to verify absence.)
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

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

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 <SKILLS>...</SKILLS> block listing every skill the agent can invoke:
The script parses out every <name> and asserts none of the denied names are present:
See the custom-system-prompt tutorial for the full shape of SystemPromptEvent.

4. Cleanup and restore

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

The account whose API key you use is the one whose disabled_skills setting the script mutates and then restores.

Run It

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 <SKILLS> 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:
Conversation 1 loaded 103 skills; conversation 2 loaded 102 (the extra flarglebargle denial removed one). Every denied name is absent from the <SKILLS> block of the SystemPromptEvent. 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.

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

no-docker

Keep the agent out of Docker and Kubernetes skills — appropriate when your sandbox base image doesn’t support nested Docker.

no-github-automations

Keep the general github skill available but hide the automation recipes (workflows, monitors, watchdogs) if they’re not for this team.
Roll your own with --disable name1 name2 name3. Skill names come from GET /api/v1/skills/search?q=<prefix>.

Per-Request Escape Hatch

Set disabled_skills on the start request to extend the deny-list for a single conversation:
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)
To verify on a specific deployment, look for disabled_skills in both the GETSettingsModel and the AppConversationStartRequest-Input schemas:
To confirm the endpoint you’re calling is the same one the UI uses, grep the enterprise frontend:

APIs Used

All calls use Authorization: Bearer <OH_API_KEY>.

custom-system-prompt

sibling tutorial covering the other half of enterprise#335: replacing the static system prompt.

custom-agent-no-browser

turn off the browser tool (a different customisation axis; deny-list here operates on skills).

load-plugin

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

OpenHands Enterprise PR #335

OpenHands SDK — Agent Settings