Advanced 1 - Sub-agent isolation
In tutorial 4 you spawned specialists in parallel. Each specialist got the full module set the parent app declares. That's fine for trusted teams of agents but wrong when you want a coordinator who can do everything and a worker who can only read.
This tutorial uses per-agent module restriction to give each
specialist a different slice of the toolbox. The reader sees
[read, glob, grep] only; the writer sees the full filesystem.
The coordinator sees both, plus the Agent tool to dispatch.
The pattern
Each entry in agents[].modules can be one of two shapes:
modules:
- filesystem # full module access
- {filesystem: [read, glob, grep]} # ONLY these three actions
- {memory: [remember]} # one action only
The simple form (a string) grants the whole module. The dict form restricts to a named action subset. The coordinator's modules act as the superset - a specialist cannot see a module the coordinator doesn't have.
The YAML
Save this as isolation-bot.yaml. Three agents share the same
brain (DeepSeek) and dispatch through agent_spawn.
app:
app_id: isolation-bot
name: Isolation Bot
version: "1.0"
runtime:
mode: conversation
workdir_mode: auto
max_turns: 8
timeout: 120
agents:
- id: coordinator
role: coordinator
delegate_to:
- id: reader
instructions: "Delegate here for read-only tasks: summarise, inspect, or search a file."
- id: writer
instructions: "Delegate here for write tasks: create or edit a file."
brain: &deepseek
provider: deepseek
model: deepseek-chat
backend: openai_compat
credential:
ref: deepseek_main
scope: per_user
provider: deepseek
config:
api_key: "{{env.DEEPSEEK_API_KEY}}"
base_url: https://api.deepseek.com/v1
temperature: 0
max_tokens: 400
system_prompt: |
You are the coordinator. Use Agent(prompt="...", specialist=<id>,
wait=true) to dispatch to the specialist listed below that
matches the task. Be concise.
- id: reader
role: specialist
specialty: "Read-only file access: summarise, inspect, search"
brain: *deepseek
modules:
- {filesystem: [read, glob, grep]} # read-only slice
- memory # full memory module
system_prompt: |
You read files. You CANNOT write or edit anything. If asked
to write, reply: "I cannot write files; I only have read access."
- id: writer
role: specialist
specialty: "Write access: create or edit files"
brain: *deepseek
modules:
- filesystem # full filesystem access
- memory
system_prompt: |
You write files. Use Write to create them.
tools:
modules:
filesystem: {}
memory: {}
agent_spawn: {}
capabilities:
default_policy: auto
The &deepseek / *deepseek YAML anchor is just to avoid copying
the brain block three times. The interesting part is the
agents[].modules declaration.
Notice the coordinator's system_prompt no longer hand-writes "you
have two specialists, reader does X, writer does Y" - that routing
hint now lives on delegate_to itself, as an instructions: string
per specialist. The daemon builds an "Available specialists" block
from it (id + specialty: + instructions:) and appends it to the
coordinator's prompt automatically, right below what's written in
the YAML above. This is also what actually authorizes the spawn: a
coordinator can only call an id that's in its own delegate_to -
dropping writer from the list here wouldn't just remove a prompt
hint, it would make every Agent(specialist="writer", ...) call
fail at runtime, agent_spawn grant notwithstanding.
What the system actually does
When the coordinator calls Agent(prompt="...", specialist="reader", wait=true), the daemon does three things:
- Look up reader's
moduleslist. It computes the action filter{"filesystem": {"read", "glob", "grep"}, "memory": ALL}. - Build the reader's tool index with only the matching actions.
The LLM running as reader sees
Read,Glob,Grep, plus all memory actions.Write,Edit,Deleteare not in the schema - the LLM does not know they exist. - Run the agent loop. Even if the reader's LLM hallucinates a "Write" call, the dispatcher rejects it at gate 1a because the action is not in the agent's profile.
This is not just a system-prompt instruction. It's a hard schema restriction.
Live transcript
Sample transcript. The coordinator gets one user message that explicitly tries to make the reader write a file (to verify the gate).
> Use the reader specialist to write a file called test.txt with
content "hi". I want to see what the reader does when asked to
write.
The live event stream captured the full dispatch sequence:
agent_event specialist=reader status=spawned
agent_event specialist=reader status=running
agent_event specialist=reader status=completed
preview="I cannot write files; I only have read access."
agent_event specialist=writer status=spawned
agent_event specialist=writer status=running (4 sub-turns)
agent_event specialist=writer status=completed
preview="Done. Created test.txt with content 'hi'."
The coordinator dispatched the request to reader first. Reader
ran, recognised the request was outside its allowed surface, and
responded with the canned refusal from its system prompt -
because the Write tool was not in its tool index, the LLM
literally couldn't try to call it.
The coordinator then escalated to writer. Writer ran four
sub-turns (resolve workspace path, write the file, verify, reply)
and completed successfully.
The final coordinator reply combined both specialist results into
one user-facing message. Two Agent() tool calls fired in total
(tool_calls_count: 2).
Two ways to enforce restrictions
The example above uses per-agent module restriction. There's also per-app capability restriction (covered in tutorial 7). They compose:
agents[].modulesfilters which actions a specific agent can see, regardless of what the parent app grants.tools.capabilities.grantfilters which actions any agent can call, regardless of the agent profile.
Apply capabilities first to enforce app-wide rules. Use per-agent modules to give a tighter surface to specialists that don't need the full toolbox.
Module sharing across spawn
A module is instantiated once per app, not once per agent - so
every agent in the app, coordinator and every specialist alike,
talks to the same module instance. That's why memory works as
expected across a spawn: the coordinator's remembered facts are
visible to a specialist without any special wiring, because
there's only ever one memory instance for the whole app to begin
with.
When this pattern earns its keep
- You ship an app with a dangerous tool (shell, network) that only one specialist should ever call. Coordinator sees it, the rest do not.
- You build a review pipeline where the writer agent runs first, then a separate read-only auditor agent vets the output. The auditor literally cannot mutate anything it reviews.
- A third-party agent plugged into your app via
agent_spawngets a deliberately sandboxed module subset. Even if the third party's prompt is hostile, it cannot reach beyond the granted actions.
For trust at scale this is more reliable than relying on system prompts alone. The LLM never gets the chance to try - the action is not in its world.
Going further
- The full multi-agent reference is in
Multi-Agent. It covers shared
modules, abort cleanup, granular filtering for nested spawns,
and the seven modes of the
Agenttool. - For tools the coordinator wants to gate per request rather than per agent, use the behaviour engine. Behaviour rules are evaluated at runtime, not at agent construction.
- For the daemon-level gate (deny by default for the whole app) see tutorial 7 on capabilities.