View source on GitHub
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
dockerwhen 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_skillson every request.
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.- 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_skillsis 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 newgerritorcodecommitskill 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.)
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 adisabled_skillslist 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.
How It Works
1. Install the account-level deny-list
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
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:
<name> and asserts none of the denied names
are present:
custom-system-prompt tutorial for
the full shape of SystemPromptEvent.
4. Cleanup and restore
try / finally), so a failed run never leaves the account with a
stuck deny-list.
Prerequisites
disabled_skills
setting the script mutates and then restores.
Run It
- Reads and remembers the current account-level
disabled_skills. - Installs the requested deny-list via
POST /api/v1/settings. - Starts a conversation with no
disabled_skillson the request, proving the account-level setting alone keeps the skills out. - Parses
<SKILLS>indynamic_context.textand asserts every disabled name is absent. - Optionally repeats with per-request
disabled_skillsto show union. - Deletes the conversation(s) and sandbox(es), and restores the original account-level setting — the beta instance is left as it was found.
Real output
Captured againsthttps://app.all-hands.dev with --per-request:
flarglebargle denial removed one). Every denied name is absent from
the <SKILLS> 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.
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.
--disable name1 name2 name3. Skill names come from
GET /api/v1/skills/search?q=<prefix>.
Per-Request Escape Hatch
Setdisabled_skills on the start request to extend the deny-list for
a single conversation:
[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)
disabled_skills in both the
GETSettingsModel and the AppConversationStartRequest-Input schemas:
APIs Used
All calls use
Authorization: Bearer <OH_API_KEY>.
Related
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.

