# tool isolation between Skills:

**URL:** <https://forum.langchain.com/t/tool-isolation-between-skills/3496>\
**Category:** Deep Agents\
**Created:** [April 20, 2026, 1:24pm UTC](https://forum.langchain.com/t/tool-isolation-between-skills/3496 "2026-04-20T13:24:46Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![chenkejin](https://avatars.discourse-cdn.com/v4/letter/c/94ad74/32.png) [@chenkejin](https://forum.langchain.com/u/chenkejin)\
**Post date:** [April 20, 2026, 1:24pm UTC](https://forum.langchain.com/t/tool-isolation-between-skills/3496/1 "2026-04-20T13:24:46Z")

</div>

How can I detect when a **DeepAgents Skill (loaded from MD files)** finishes execution? I need to implement **strict tool isolation between Skills** : when one Skill is running, the agent can only use its allowed tools, and the tool list must be **restored immediately after the Skill ends** , not after the entire agent turn ends.

---

<div class="post-metadata">

**Author:** ![pawel-twardziak](https://yyz1.discourse-cdn.com/flex007/user_avatar/forum.langchain.com/pawel-twardziak/32/960_2.png) [@pawel-twardziak](https://forum.langchain.com/u/pawel-twardziak)\
**Post date:** [April 20, 2026, 3:56pm UTC](https://forum.langchain.com/t/tool-isolation-between-skills/3496/2 "2026-04-20T15:56:52Z")

</div>

Hi @chenkejin

What you’re asking for doesn’t map onto Skills as Deep Agents implements them, and trying to force it will be fragile.

Skills are prompt fragments loaded by progressive disclosure, not runtime execution units - there is no `skill_started` / `skill_ended` event to hook, and the `allowed-tools` field in `SKILL.md` is, today, only a hint printed in the system prompt; it is not enforced by `SkillsMiddleware`.

If you genuinely need strict, enforceable, per-capability tool scoping with a clean “start” and “end”, the idiomatic fix is to model each Skill as a Subagent (`SubAgentMiddleware` / the `task` tool).

If you must keep Skills as `SKILL.md` files, you can approximate scoping with a custom middleware that overrides `request.tools` via `request.override(tools=...)` based on a state flag - but you have to generate the start/end signal yourself.

Below are the three viable paths, ranked by how well they satisfy your real requirements.

**1. Skills are system-prompt content, not a subgraph**

`SkillsMiddleware` loads each `SKILL.md` frontmatter ahead of time and injects a “Skills System” section into the system prompt containing names, descriptions, paths, and - if present - a textual listing of `allowed-tools`. The agent then reads the full skill file via `read_file` inside its normal turn and continues calling tools from the single, already-bound toolset. There’s no “enter skill” and “exit skill” node in the graph at all.

Relevant code - `sources/deepagents/libs/deepagents/deepagents/middleware/skills.py`:

```python
# The "Skills System" text is appended to the system message every model call.
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
    skills_metadata = request.state.get("skills_metadata", [])
    skills_locations = self._format_skills_locations()
    skills_list = self._format_skills_list(skills_metadata)
    skills_section = self.system_prompt_template.format(
        skills_locations=skills_locations,
        skills_list=skills_list,
    )
    new_system_message = append_to_system_message(request.system_message, skills_section)
    return request.override(system_message=new_system_message)

```

And the rendering of `allowed_tools` - it’s pure text, never consulted by a tool gate:

```python
# _format_skills_list
if skill["allowed_tools"]:
    lines.append(f" -> Allowed tools: {', '.join(skill['allowed_tools'])}")

```

Docs confirming this: the agent “reads the full `SKILL.md` file using the path shown in its skills list” and then “follows the skill’s instructions” - all inside the same turn, same tool binding.  
→ [https://docs.langchain.com/oss/python/deepagents/skills](https://docs.langchain.com/oss/python/deepagents/skills)

**Consequences for your ask:**

- There is no lifecycle signal you can subscribe to that says “skill X just finished”. The model alone decides when it’s “done with the skill” and silently moves on.
- Even if you could reliably detect the read of `SKILL.md`, the model keeps calling tools from the same flat toolset; your “restore immediately after the Skill ends” has nowhere to hook.

**2. `allowed-tools` is currently advisory, not enforced**

From the parser, `allowed-tools` is captured and then surfaced as a line in the system prompt. There is **no** `_check_allowed_tools` or `wrap_tool_call` that rejects a disallowed call anywhere in `SkillsMiddleware` (the tests in `tests/unit_tests/middleware/test_skills_middleware.py` exercise parsing only, not enforcement). A jailbreak is just the model ignoring the paragraph you printed.

> This is labelled in the type itself: `allowed_tools` is annotated _Warning: this is experimental_ in `SkillMetadata` (`skills.py`, line ~186).

So relying on `allowed-tools` for isolation is, today, purely a prompt-level guideline.

**Model each Skill as a Subagent**

This is the only path that gives you actual, framework-enforced tool isolation _and_ a clean start/end boundary.

- Subagents are compiled with their own `tools` list; the parent model never sees those tools, and the subagent’s model never sees the parent’s tools.
- The “start” is the `task(subagent_type=...)` tool call; the “end” is the subagent returning its final message - you get exactly the “restored immediately after the skill ends” semantic you asked for, because once the `task` tool returns, the main agent is back with its own original tools.

Minimal shape:

```python
from deepagents import create_deep_agent

web_research = {
    "name": "web-research",
    "description": "Structured web research workflow (use when the user asks to research a topic).",
    "system_prompt": open("./skills/web-research/SKILL.md").read(), # move the skill body here
    "model": "openai:gpt-4.1",
    "tools": [web_search, read_file], # <-- the only tools this skill can call
}

sql_helper = {
    "name": "sql-helper",
    "description": "Answer questions that require SQL against the internal warehouse.",
    "system_prompt": open("./skills/sql-helper/SKILL.md").read(),
    "model": "openai:gpt-4.1",
    "tools": [run_sql, describe_table], # <-- completely disjoint toolset
}

agent = create_deep_agent(
    model="openai:gpt-4.1",
    tools=[], # main agent has no domain tools, only `task`
    subagents=[web_research, sql_helper],
)

```

Why this is the right answer:

- Tool isolation is enforced at the graph level, not by prompt discipline. A subagent physically cannot emit a tool call the parent owns.
- The “skill end” is a deterministic, framework-provided event: it’s the `ToolMessage` returned for the `task` call. You can hook it with `wrap_tool_call` on the parent if you need a callback; see the custom-middleware pages.
- You keep progressive disclosure: the parent only sees `{name, description}` in the `task` tool description - the full SKILL.md body only ever enters the subagent’s context.
- The docs explicitly support this layering - see “Skills for subagents” and note that _skill state is fully isolated: the main agent’s skills are not visible to subagents, and subagent skills are not visible to the main agent._  
→ [https://docs.langchain.com/oss/python/deepagents/skills#skills-for-subagents](https://docs.langchain.com/oss/python/deepagents/skills#skills-for-subagents)  
→ [https://docs.langchain.com/oss/python/deepagents/subagents](https://docs.langchain.com/oss/python/deepagents/subagents)

Trade-offs to be aware of:

- Each “skill call” is a full extra LLM round-trip (spawn, run, return). For very short skills this is more expensive than just reading a markdown section inline.
- Subagent invocations are stateless across calls (per `TASK_TOOL_DESCRIPTION` in `subagents.py`, line 203: _“Each agent invocation is stateless”_). Persist anything you need across calls in the filesystem/state backend.

If you want to keep authoring your skills as `SKILL.md` files (with frontmatter), write a small loader that produces the `SubAgent` dicts from disk - the `SKILL.md` format is the same; only the _consumer_ changes from `SkillsMiddleware` to `SubAgentMiddleware`.

**Dynamic tool scoping via custom middleware**

If converting to subagents is too invasive, you can scope tools per model call by writing middleware that overrides `request.tools` based on a state field the model sets itself. This is the officially documented escape hatch for “modify the set of tools available to the agent at runtime”:

→ [https://docs.langchain.com/oss/python/langchain/agents#dynamic-tools](https://docs.langchain.com/oss/python/langchain/agents#dynamic-tools)  
→ [https://docs.langchain.com/oss/python/langchain/middleware/custom](https://docs.langchain.com/oss/python/langchain/middleware/custom)

Shape:

```python
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
from langchain_core.tools import tool
from langgraph.types import Command

class SkillScopeMiddleware(AgentMiddleware):
    """Scope `request.tools` to the currently active skill, if any."""

    def __init__ (self, skill_tool_map: dict[str, set[str]]) -> None:
        super(). __init__ ()
        # e.g. {"web-research": {"web_search", "read_file"}, ...}
        self._skill_tool_map = skill_tool_map

    def wrap_model_call(self, request: ModelRequest, handler):
        active = request.state.get("active_skill")
        if active and active in self._skill_tool_map:
            allowed = self._skill_tool_map[active]
            # Keep framework/control tools (task, todo, filesystem) - only gate domain tools
            filtered = [t for t in request.tools if t.name in allowed or t.name in CONTROL_TOOLS]
            request = request.override(tools=filtered)
        return handler(request)

```

You also need the model to signal start and end explicitly, because the framework won’t:

```python
@tool
def begin_skill(name: str) -> Command:
    """Enter a skill's tool scope. Call before using a skill's capabilities."""
    return Command(update={"active_skill": name})

@tool
def end_skill() -> Command:
    """Leave the current skill's tool scope. Call as soon as the skill is finished."""
    return Command(update={"active_skill": None})

```

Then add a sentence to each `SKILL.md`: _“Call `begin_skill("<name>")` before you do anything, and `end_skill()` as soon as you finish.”_

Why this is strictly weaker than subagents:

- Model-driven, not framework-driven. If the model forgets to call `end_skill`, scope never restores. You can harden this by having a hook (`after_model`) clear `active_skill` when the model returns a non-tool-call message, but that’s still best-effort.
- Same context window. You don’t get the token savings of a subagent.
- Harder to audit. There’s no trace “this call happened under skill X” unless you add it.

Use this only if you can’t pay the latency/cost of a subagent round-trip.

**Key API reference for the middleware path**

- `ModelRequest.override(tools=...)` - the supported way to swap tools mid-request. Examples of this exact pattern live in `langchain/libs/langchain_v1/langchain/agents/middleware/tool_selection.py` (`LLMToolSelectorMiddleware._process_selection_response`, line 272) and in the agents docs section _“Dynamic tools”_ linked above.
- `wrap_tool_call` - if you also want to _block_ a disallowed call defensively (e.g., return a `ToolMessage` saying “not allowed in skill X”) instead of just hiding the tool from the model.

**Hybrid pattern (often the pragmatic choice)**

Keep `SkillsMiddleware` for discovery and documentation of your skill library, but declare each skill that needs real isolation twice:

1. Once as a `SKILL.md` under `/skills/.../` (so it shows up in the progressive-disclosure listing and can be authored like any other skill).
2. Once as a Subagent whose `system_prompt` is the body of that same `SKILL.md`. The main agent’s prompt can say: _“For skill X, prefer calling `task(subagent_type='X')` over reading `SKILL.md` inline.”_

That way low-stakes skills stay cheap (inline read), and high-stakes skills - the ones where `allowed-tools` actually matter

**Direct answers to your two requirements**

1. **“Detect when a Skill finishes execution”** - there is no such event for `SkillsMiddleware`-loaded skills. The only deterministic “finish” signal available today is a Subagent returning (the `ToolMessage` produced by the `task` tool). Hook it with `wrap_tool_call` on the parent if you need a callback.

2. **“Tool list restored immediately after the Skill ends, not after the turn”** - the Subagent approach gives this for free (tools are restored the instant the `task` tool returns, not at end-of-turn). The middleware approach gives it approximately, but only if the model reliably calls your `end_skill` tool - which is a prompt-discipline problem.

## Sources

- Deep Agents - Skills (official docs): [https://docs.langchain.com/oss/python/deepagents/skills](https://docs.langchain.com/oss/python/deepagents/skills)
- Deep Agents - Subagents: [https://docs.langchain.com/oss/python/deepagents/subagents](https://docs.langchain.com/oss/python/deepagents/subagents)
- LangChain - Agents, “Dynamic tools” section: [https://docs.langchain.com/oss/python/langchain/agents#dynamic-tools](https://docs.langchain.com/oss/python/langchain/agents#dynamic-tools)
- LangChain - Custom middleware (`wrap_model_call`, `wrap_tool_call`, `ModelRequest.override`): [https://docs.langchain.com/oss/python/langchain/middleware/custom](https://docs.langchain.com/oss/python/langchain/middleware/custom)
- Agent Skills Specification (frontmatter semantics, including `allowed-tools`): [https://agentskills.io/specification](https://agentskills.io/specification)
- Source (repo) - `SkillsMiddleware` implementation (shows `allowed_tools` is prompt-only today): `deepagents/libs/deepagents/deepagents/middleware/skills.py`
- Source (repo) - `SubAgentMiddleware` and the `task` tool (real tool isolation): `deepagents/libs/deepagents/deepagents/middleware/subagents.py`
- Source (repo) - `LLMToolSelectorMiddleware` as a reference for `request.override(tools=...)`: `langchain/libs/langchain_v1/langchain/agents/middleware/tool_selection.py`
