Deep Agents skills aren't just prompts. They can ship real code — a script the agent runs, not just reads.
That raises one question: where does that code actually execute?
I built a small arxiv-search skill to answer this. The agent reads instructions, runs a bundled Python script to search arXiv, saves the raw JSON as an artifact, validates it, and summarizes the papers — all while the script itself runs inside an isolated Daytona sandbox instead of your machine.
This post walks through the setup, then gives you the full, runnable code.
The core idea, in one picture
Skills and sandboxes are two different worlds:
- The skill store — durable, inspectable, easy to version. Good for storing files.
- The sandbox — isolated, disposable, safe to execute untrusted code in. Good for running files.
An agent reading SKILL.md from the store does not mean the sandbox has a copy of search.py. Something has to physically move the file across that boundary before it can run. That "something" is a middleware hook.
StoreBackend (durable) Daytona sandbox (disposable)
------------------------ ----------------------------
/skills/arxiv-search/SKILL.md -> /home/daytona/skills/arxiv-search/SKILL.md
/skills/arxiv-search/scripts/search.py -> /home/daytona/skills/arxiv-search/scripts/search.py
That's the whole architecture. Everything below is just implementation detail.
Folder structure
project-root/
├── sandbox_skill_scripts.py # agent, store, sandbox, middleware
└── skills/
└── arxiv-search/
├── SKILL.md # when + how to use the skill
└── scripts/
└── search.py # the actual arXiv API call
At startup, the agent only sees the skill's name and description (progressive disclosure). It reads the full SKILL.md only once a request matches. It touches search.py only when the instructions tell it to run it.
How a request flows end to end
1. Agent sees "arxiv-search" name + description.
2. User's request matches → agent reads SKILL.md.
3. before_agent middleware uploads SKILL.md + search.py from the store → Daytona.
4. Agent runs search.py inside the sandbox.
5. search.py hits the arXiv API, prints JSON.
6. Agent saves + validates that exact JSON as an artifact.
7. Agent summarizes the papers for the user.
8. after_agent middleware downloads the JSON artifact back out of the sandbox.
9. Sandbox is deleted. Always — even on failure.
Step 9 matters more than it looks. A sandbox is temporary infrastructure; the finally block in the script guarantees cleanup even if the model call blows up.
Why bother with a sandbox at all?
Not just security. It's a predictable execution environment: temp files, dependencies, CLI tools, validation — all without treating your laptop as the agent's workspace. In production, that boundary is what lets you:
- keep shared skills read-only,
- require review before a skill script changes,
- persist artifacts outside an ephemeral sandbox,
- inspect a failure without exposing your host filesystem.
Sandboxing doesn't make an unsafe script safe on its own — treat every skill script like application code: review it, restrict its permissions, validate its inputs.
Run it yourself
Set credentials in .env:
DAYTONA_API_KEY=...
NVIDIA_API_KEY=...
Optional:
NVIDIA_MODEL=meta/llama-3.1-8b-instruct
NVIDIA_TIMEOUT_SECONDS=180
Then, from the project root:
uv run python sandbox_skill_scripts.py
It prints the agent's response, previews the JSON artifact copied back from the sandbox, and tears the sandbox down.
The full code
Copy these three files into the structure shown above and you're ready to run.
skills/arxiv-search/SKILL.md
---
name: arxiv-search
description: Search the arXiv preprint repository for research papers. Use when the user asks about academic papers, recent research, or scientific literature.
---
# arxiv-search
Search arXiv papers by following these steps:
1. Run `python /home/daytona/skills/arxiv-search/scripts/search.py "<query>" --max-results 3` in the Daytona sandbox.
2. Parse the JSON output and present each paper's title, authors, abstract summary, and link.
3. If the user asks for more detail about a paper, run the script again with a more specific query.
skills/arxiv-search/scripts/search.py
import argparse
import json
import sys
import xml.etree.ElementTree as ET
from urllib.parse import urlencode
from urllib.request import Request, urlopen
ATOM = "{http://www.w3.org/2005/Atom}"
def search_arxiv(query: str, max_results: int) -> dict:
params = urlencode(
{
"search_query": f"all:{query}",
"start": 0,
"max_results": max_results,
}
)
request = Request(
f"https://export.arxiv.org/api/query?{params}",
headers={"User-Agent": "deepagents-sandbox-skill-example/1.0"},
)
with urlopen(request, timeout=30) as response:
root = ET.fromstring(response.read())
papers = []
for entry in root.findall(f"{ATOM}entry"):
authors = [
author.findtext(f"{ATOM}name", default="")
for author in entry.findall(f"{ATOM}author")
]
alternate_link = next(
(
link.get("href", "")
for link in entry.findall(f"{ATOM}link")
if link.get("rel") == "alternate"
),
"",
)
papers.append(
{
"title": " ".join(
(entry.findtext(f"{ATOM}title") or "").split()
),
"authors": authors,
"abstract": " ".join(
(entry.findtext(f"{ATOM}summary") or "").split()
),
"link": alternate_link,
"published": entry.findtext(f"{ATOM}published", default=""),
}
)
return {"query": query, "results": papers}
def main() -> int:
parser = argparse.ArgumentParser(description="Search arXiv papers")
parser.add_argument("query")
parser.add_argument("--max-results", type=int, default=3)
args = parser.parse_args()
if not 1 <= args.max_results <= 10:
parser.error("--max-results must be between 1 and 10")
try:
result = search_arxiv(args.query, args.max_results)
except Exception as error:
print(f"arXiv request failed: {error}", file=sys.stderr)
return 1
print(json.dumps(result, indent=2, ensure_ascii=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
sandbox_skill_scripts.py
"""Run a skill script inside an isolated Daytona sandbox.
The skill package lives in the sibling ``skills/arxiv-search/`` directory.
The runner loads that directory into an InMemoryStore. The agent can read the
files through StoreBackend, but the script can execute only after the
middleware uploads both files into the sandbox. The after-agent hook copies
the optional JSON result back into the store.
Prerequisites:
DAYTONA_API_KEY in .env for the managed sandbox
NVIDIA_API_KEY in .env for the default model
Run from the repository root:
uv run python sandbox_skill_scripts.py
"""
import json
import os
from pathlib import Path, PurePosixPath
from daytona import Daytona
from dotenv import load_dotenv
from deepagents import create_deep_agent
from deepagents.backends import CompositeBackend, StoreBackend
from deepagents.backends.utils import create_file_data
from langchain.agents.middleware import AgentMiddleware, ModelResponse
from langchain_core.messages import AIMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_daytona import DaytonaSandbox
from langgraph.store.memory import InMemoryStore
from requests.exceptions import ReadTimeout
SKILL_NAMESPACE = ("skills", "builtin")
ARTIFACT_NAMESPACE = ("artifacts", "sandbox-skill-demo")
SKILL_ROUTE = "/skills/"
ARTIFACT_PATH = "/outputs/arxiv-results.json"
SANDBOX_WORK_DIR = "/home/daytona"
SANDBOX_SKILLS_DIR = f"{SANDBOX_WORK_DIR}/skills"
SANDBOX_ARTIFACT_PATH = f"{SANDBOX_WORK_DIR}/outputs/arxiv-results.json"
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
def seed_skill_store(
store: InMemoryStore,
skills_dir: Path = SKILLS_DIR,
) -> None:
"""Load an on-disk skill directory into the StoreBackend namespace."""
if not skills_dir.is_dir():
raise FileNotFoundError(f"Skills directory not found: {skills_dir}")
for file_path in sorted(
path
for path in skills_dir.rglob("*")
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc"
):
relative_path = file_path.relative_to(skills_dir).as_posix()
store.put(
SKILL_NAMESPACE,
f"/{relative_path}",
create_file_data(file_path.read_text(encoding="utf-8")),
)
def sandbox_path(store_path: str) -> str:
"""Map a StoreBackend key to the matching sandbox skill path."""
normalized = store_path if store_path.startswith("/") else f"/{store_path}"
path_parts = PurePosixPath(normalized).parts
if ".." in path_parts or any(character in normalized for character in "*?"):
raise ValueError(f"Unsafe skill path: {store_path}")
return f"{SANDBOX_SKILLS_DIR}{normalized}"
class SandboxSkillSyncMiddleware(AgentMiddleware):
"""Move skill files into the sandbox before execution and results back after."""
def __init__(self, sandbox_backend: DaytonaSandbox) -> None:
super().__init__()
self.sandbox_backend = sandbox_backend
def before_agent(self, state, runtime):
files = []
for item in runtime.store.search(SKILL_NAMESPACE, limit=100):
content = item.value.get("content")
encoding = item.value.get("encoding", "utf-8")
if not isinstance(content, str) or encoding != "utf-8":
raise ValueError(f"Only UTF-8 text skills are supported: {item.key}")
files.append((sandbox_path(str(item.key)), content.encode("utf-8")))
if not files:
raise RuntimeError("No skill files were found in the skill store")
responses = self.sandbox_backend.upload_files(files)
failures = [response for response in responses if response.error]
if failures:
details = ", ".join(
f"{response.path}: {response.error}" for response in failures
)
raise RuntimeError(f"Skill upload failed: {details}")
print(f"Uploaded {len(files)} skill files into the sandbox")
return None
def after_agent(self, state, runtime):
result = self.sandbox_backend.download_files([SANDBOX_ARTIFACT_PATH])[0]
if result.content is None:
if result.error == "file_not_found":
print("No result artifact was created by the agent")
return None
raise RuntimeError(f"Artifact download failed: {result.error}")
runtime.store.put(
ARTIFACT_NAMESPACE,
ARTIFACT_PATH,
create_file_data(result.content.decode("utf-8")),
)
print(f"Synced {ARTIFACT_PATH} back into the store")
return None
class SingleToolCallMiddleware(AgentMiddleware):
"""Keep model history compatible with NVIDIA's single-call tool template."""
def wrap_model_call(self, request, handler):
response = handler(request)
truncated_messages = []
for message in response.result:
if not isinstance(message, AIMessage) or len(message.tool_calls) <= 1:
truncated_messages.append(message)
continue
additional_kwargs = dict(message.additional_kwargs)
raw_tool_calls = additional_kwargs.get("tool_calls")
if isinstance(raw_tool_calls, list):
additional_kwargs["tool_calls"] = raw_tool_calls[:1]
truncated_messages.append(
message.model_copy(
update={
"tool_calls": message.tool_calls[:1],
"additional_kwargs": additional_kwargs,
}
)
)
if truncated_messages == response.result:
return response
return ModelResponse(
result=truncated_messages,
structured_response=response.structured_response,
)
def main() -> None:
load_dotenv()
model_name = os.getenv(
"NVIDIA_MODEL",
"meta/llama-3.1-8b-instruct",
)
if model_name.startswith("nvidia:"):
model_name = model_name.removeprefix("nvidia:")
timeout_seconds = int(os.getenv("NVIDIA_TIMEOUT_SECONDS", "180"))
model = ChatNVIDIA(
model=model_name,
timeout=timeout_seconds,
max_completion_tokens=4096,
model_kwargs={"parallel_tool_calls": False},
)
store = InMemoryStore()
seed_skill_store(store)
print("Creating Daytona sandbox (this can take a moment)...")
sandbox = Daytona().create()
print(f"Created sandbox: {sandbox.id}")
sandbox_backend = DaytonaSandbox(sandbox=sandbox)
backend = CompositeBackend(
default=sandbox_backend,
routes={
SKILL_ROUTE: StoreBackend(
store=store,
namespace=lambda _runtime: SKILL_NAMESPACE,
),
},
)
agent = create_deep_agent(
model=model,
backend=backend,
store=store,
skills=[SKILL_ROUTE],
middleware=[
SandboxSkillSyncMiddleware(sandbox_backend),
SingleToolCallMiddleware(),
],
system_prompt=(
"You are a research assistant with an isolated execution sandbox. "
"Use the arxiv-search skill when the user asks about papers. "
"Daytona's writable directory is /home/daytona; keep generated "
"files there and do not write directly under /. "
"Make at most one tool call per response; wait for its result "
"before making another tool call."
),
)
prompt = (
"Use the arxiv-search skill to find three papers about retrieval augmented "
"generation. You must read the skill, execute its bundled script inside "
"the sandbox, and use the script's JSON output. Save that exact JSON to "
f"{SANDBOX_ARTIFACT_PATH}, then summarize the papers with title, authors, "
"abstract summary, and link. Do not answer from memory. The artifact must "
"be valid JSON copied from the script output, not a Python dictionary "
"repr or fabricated example. Before finishing, run `python -m json.tool "
f"{SANDBOX_ARTIFACT_PATH}` and fix the file if validation fails."
)
try:
result = agent.invoke(
{"messages": [{"role": "user", "content": prompt}]},
config={"configurable": {"thread_id": "sandbox-skill-demo"}},
)
print("\nAgent response:\n")
print(result["messages"][-1].content)
artifact = store.get(ARTIFACT_NAMESPACE, ARTIFACT_PATH)
if artifact:
print("\nStored artifact preview:\n")
artifact_content = artifact.value["content"]
try:
artifact_json = json.loads(artifact_content)
except json.JSONDecodeError:
print("Artifact is not valid JSON; raw content preview:\n")
print(artifact_content[:1000])
else:
print(json.dumps(artifact_json, indent=2)[:1000])
except ReadTimeout as error:
raise SystemExit(
f"The NVIDIA model did not respond within {timeout_seconds} seconds. "
"Try a faster model such as meta/llama-3.2-3b-instruct or increase "
"NVIDIA_TIMEOUT_SECONDS."
) from error
finally:
sandbox.delete(wait=True)
print(f"\nDeleted sandbox: {sandbox.id}")
if __name__ == "__main__":
main()
What I'd add next
- Swap
InMemoryStorefor a persistent store once skills or artifacts need to survive a restart. - As skills grow past one script, have
SKILL.mdexplicitly list every supporting file it depends on. - Mock the arXiv API response and add real tests for
search_arxiv(). - Lock down the
/skills/route so agents can read shared skills but never write to them.
The one-sentence takeaway
Store skills where they can be managed, execute them where they can be isolated, and sync only the files that need to cross that boundary.
Once that clicks, sandboxed skills stop feeling like magic — they're just a clean way to give an agent reusable, tested capabilities without turning your machine into its workspace.
References
Top comments (0)