●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 44 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 44 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
The Morning WS Came Back Empty in a Scheduled Task, and I Learned There Were Two Shells
Step 1 of a scheduled task left my workspace variable empty. The cause was a second shell. Here is what the same probe returned in both, and a tool_result guard that turns the silence into an error.
On a Saturday evening, as an indie developer running a few scheduled tasks of my own, I ran step 1 of one of them. It is the usual single line that puts the location of the workspace into a variable called WS.
What came back was an empty string. Not one line of error. The exit code was 0.
I happen to have a habit of echoing a variable before I trust it, so I noticed on the spot. Had I not, the next step, cat "${WS}/_skills/…", would have answered "no such file". And that message would have pointed at a path under the root directory, not at the real cause.
This article is a record of what I learned that day. When an agent has two shells, a mix-up does not show up as a failure. It shows up as nothing. Below are the measures I added to turn nothing into a failure, together with what I actually ran.
Step 1 left the variable empty, and I almost kept going
What happened is simple. The task began by finding the workspace with bash and reading a settings file. The first thing I called was the standard bash tool, which runs on the cloud side.
But this environment has two shells: the cloud-side bash, and a shell running in a virtual machine on my own computer. Only the second one has the workspace folder attached.
When I searched from the cloud side, ls -d found nothing. Because 2>/dev/null | head -1 was attached at the end, the fact that nothing was found simply flowed on as an empty string.
What makes this setup frightening is that even the wrong shell runs most commands normally. The next section puts the two side by side.
The two shells look almost identical, but they see different things
I ran the same probe script in both shells and compared. Here it is. I have replaced the workspace folder name with Workspace; the real runs used the actual name.
#!/bin/bash# probe.sh — find out which shell you are in. Run the same file in both.echo "host=$(hostname)"; echo "home=$HOME"; echo "pwd=$(pwd)"echo "sessions_dir=$([ -d /sessions ] && echo yes || echo no)"echo "ws_glob=[$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)]"echo "git=$(git --version 2>&1)"; echo "node=$(node -v 2>&1)"; echo "py=$(python3 --version 2>&1)"s=$(date +%s%N)curl -s -o /dev/null -m 10 -w 'github_api=%{http_code}' https://api.github.com/zenecho " ($(( ($(date +%s%N)-s)/1000000 ))ms)"
The results are below.
Item
Cloud-side shell
Desktop-side shell
Hostname
vm
claude
Python
3.13.16
3.10.12
git
2.43.0
2.34.1
Node.js
v22.22.0
v22.23.2
/sessions directory
absent
present
Workspace folder
not found (empty string)
found
curl to api.github.com/zen
403 (1,352 ms)
200 (98 ms)
git, Python, and Node run in both. What differs is the versions, the folders you can see, and how outbound traffic gets through.
One more detail that quietly matters is the Python version: 3.13 on the cloud side, 3.10 on the desktop side. The same script can work in only one of them depending on how it is written.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can now write into your agent's tool definitions which shell sees which files, and stop a mix-up before the command runs
✦You can now catch the failure that returns an empty string with exit code 0, using is_error on tool_result
✦You can now prevent the accident where an empty path variable sends a command to the root directory, with one line placed before it
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
An empty string passes straight through with exit code 0
Next I checked how the empty value slips through, in the cloud-side shell.
# 1) exit status of the assignmentWS="$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)"echo "assign_status=$? ws=[$WS]"# → assign_status=0 ws=[]# 2) with pipefail turned onset -o pipefailWS="$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)"echo "pipefail_assign_status=$? ws=[$WS]"# → pipefail_assign_status=2 ws=[]# 3) using the empty value latercat "$WS/_skills/x/SKILL.md"# → cat: /_skills/x/SKILL.md: No such file or directory
The first assignment passes with exit code 0, because the status of a pipeline is the status of its last command, here head. The second returns 2 under pipefail, but the variable is still empty.
The third is the one that made me sit up straight. An empty WS does not vanish from the path. It turns into /_skills/…, a path directly under the root. Here it was only cat, so the cost was a file I could not read. Had it been rm -rf "$WS/tmp", I would rather not finish that thought.
The fix is one line.
: "${WS:?This shell cannot see the workspace. Switch to the desktop-side shell.}"
${variable:?message} prints the message and stops a non-interactive shell when the variable is empty. In the cloud-side shell the message appeared and the exit code was 127; the echo reached right after it never ran. In the desktop-side shell the same line passed straight through and reached printed.
Measure one: make the shell announce itself right before step 1
I now put these three lines at the top of the scheduled task's prompt. The run log always shows which shell I landed in.
WS="$(ls -d /sessions/*/mnt/Workspace 2>/dev/null | head -1)"echo "shell=$(hostname) py=$(python3 -c 'import platform;print(platform.python_version())') ws=[${WS:+found}]": "${WS:?This shell cannot see the workspace. Switch to the desktop-side shell.}"
On the desktop side it printed shell=claude py=3.10.12 ws=[found] and went on. On the cloud side it printed shell=vm py=3.13.16 ws=[] and stopped. The log says only found, not the path itself, so that a session-specific identifier does not end up in the log.
Put the line that checks for empty before the line that uses the value. That is the whole principle.
Measure two: return an empty output as a failed tool_result
So far this was about the shell. If you build an agent on the Messages API, you can build the same protection into how tool results are returned.
The twoshell_guard.py below runs on the standard library alone. It has two parts: fingerprint(), which inspects the shell a tool runs in, and dispatch(), which shapes the result into a tool_result.
"""A thin guard for agents that have two shells. Standard library only."""import glob, json, os, platform, socket, subprocess, sysWS_NAME = "Workspace" # workspace folder name (example)# A "contract" per tool. Keep it in code as well as in the description.CONTRACT = { "run_in_sandbox": {"needs_workspace": False}, "run_on_desktop": {"needs_workspace": True},}def fingerprint(): """Who is this shell? Always return something, never an empty string.""" found = glob.glob(f"/sessions/*/mnt/{WS_NAME}") + glob.glob( os.path.expanduser(f"~/mnt/{WS_NAME}") ) return { "host": socket.gethostname(), "python": platform.python_version(), "has_sessions": os.path.isdir("/sessions"), "workspace": found[0] if found else None, }def dispatch(tool_use_id, name, command, fp, expect_empty=False): """Run a tool_use and build a tool_result you can send straight back.""" need = CONTRACT[name]["needs_workspace"] if need and not fp["workspace"]: return { "type": "tool_result", "tool_use_id": tool_use_id, "is_error": True, "content": f"{name} runs on a host (host={fp['host']}) with no workspace folder. " f"Check which executor this tool is wired to.", } p = subprocess.run(["bash", "-c", command], capture_output=True, text=True, timeout=30) out = p.stdout.strip() empty_is_error = (not out) and (not expect_empty) if p.returncode != 0 or empty_is_error: why = f"exit={p.returncode}" if p.returncode else "exit=0 but the output is empty" return { "type": "tool_result", "tool_use_id": tool_use_id, "is_error": True, "content": f"{why}. stderr={p.stderr.strip()[:200]!r}. " f"Check whether a resolved path is empty, or whether the command ran in the wrong shell.", } return {"type": "tool_result", "tool_use_id": tool_use_id, "content": out}
I ran this exact file in both shells; the checksums matched. The ones I ran carried the messages in Japanese, and the structure is otherwise identical to what is shown here. At the bottom I sent the same ls -d as step 1 under two different contracts.
On the cloud side, run_on_desktop, which needs the workspace, was stopped before anything ran.
{"host": "vm", "python": "3.13.16", "has_sessions": false, "workspace": null}→ is_error: true "run_on_desktop runs on a host (host=vm) with no workspace folder. Check which executor this tool is wired to."
When I sent the workspace-finding command through run_in_sandbox, whose contract does not need a workspace, this came back.
→ is_error: true "exit=0 but the output is empty. stderr=''. Check whether a resolved path is empty, or whether the command ran in the wrong shell."
On the desktop side, the same two calls both returned the workspace path and succeeded. The file ran without trouble on Python 3.10.12 as well.
What this guard looks at is not the name of the tool the model chose, but who the executor actually is. If a command written for run_in_sandbox is routed to the desktop executor, the guard lets it through. A wiring mistake itself cannot be caught by looking at tool names.
Measure three: write what the tool can see into its description
The third measure is in the tool definitions you hand to the model. When two tools with similar names do different things, the model can only choose from the descriptions. So the description should say where the tool runs and what it can see.
import anthropicimport twoshell_guard as gMODEL = "claude-sonnet-5-5"TOOLS = [ { "name": "run_in_sandbox", "description": ( "Runs a command in a disposable cloud-side sandbox. " "It can see only /tmp and its own scratch directory, not the user's folders. " "Outbound network access may be restricted." ), "input_schema": { "type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"], }, }, { "name": "run_on_desktop", "description": ( "Runs a command in a shell on the user's own computer. " "The connected workspace is at $HOME/mnt/Workspace. " "Always use this tool to read or write the user's files. " "By default, files inside the connected folder cannot be deleted." ), "input_schema": { "type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"], }, },]def run_turn(client, messages, fps): """Advance one turn. fps is {tool name: fingerprint()} collected at startup.""" resp = client.messages.create( model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages ) messages.append({"role": "assistant", "content": resp.content}) results = [ g.dispatch(b.id, b.name, b.input["command"], fps[b.name]) for b in resp.content if b.type == "tool_use" ] if results: messages.append({"role": "user", "content": results}) return resp.stop_reason
A caveat first. I wrote this loop in an environment without an API key, so I have not made a live API call. What I actually ran is dispatch and below. For the SDK types, I only confirmed in the type definitions that ToolParam has name, description, and input_schema, and that ToolResultBlockParam has tool_use_id, content, and is_error.
The description is a plain-language copy of the contract in fingerprint(). If you keep the description and the contract separately, they will drift apart someday. I write CONTRACT first and derive the description from it.
What I hand to which shell, and where I draw the line
On that day I fetched the repository from the desktop-side shell. A bare curl from the cloud side to GitHub returned the 403 in the table above, and the timings were 1,352 ms against 98 ms, about 13.8 times apart. It is a single observation, so please read it as a sign that the paths differ, not as a speed comparison.
In my own setup I divide the work like this.
Anything that reads or writes files in the workspace goes to the desktop side
Anything that connects to an outside service is handed over only after I have checked outbound traffic on the desktop side
Throwaway computation and checks that never touch my files run on the cloud side
The idea is to put the work that does the least harm when it lands on the wrong side onto the cloud side. I recommend building the avoidance into the procedure rather than relying on anyone's attention, and, in any setup where shells with different reach get mixed, making the one-line self-announcement the default.
The pitfalls were on the side that makes empty look normal
Here are three places I nearly stepped on.
The first is a command whose empty result is actually correct: a check where grep finding nothing is the healthy outcome, or a call where you throw the output away and look only at the exit code. Pass expect_empty=True to dispatch, so that the caller names the places where empty is allowed. The fewer places you allow, the better the guard works.
The second is reusing a fingerprint. Take fingerprint() once at startup and store it per tool's executor. Re-taking it every turn only costs time and does not prevent an executor from being swapped midway. If swapping can happen, add one line before the call that checks whether host still matches the stored value.
The third is keeping the path itself out of descriptions and logs. The desktop-side path in my runs contained a session-specific identifier. Write $HOME/mnt/Workspace with the variable in the description, and just found in the log.
What you can do today
Try adding these three things to your own agent or scheduled task.
Right before step 1, put the echo that announces the shell and the : "${WS:?…}" line
In the function that returns tool results, mark an empty output with exit code 0 as is_error
In each tool description, write in one sentence which folders the tool can and cannot see
While the second shell looked "almost the same", I assumed no mix-up was happening. It only becomes visible when the value goes empty.
A mix-up between shells is quiet by nature, so the only reliable moment to catch it is the one you build in yourself.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.