The first time I noticed something was wrong, the scheduled task log just said "triggered at 12:00" and then nothing. The status stayed on "running" for hours. No stack trace, no timeout, no error. When I ran the same skill manually, it finished in two minutes.
It took me about two weeks to figure out the culprit was a permission dialog. Tools like Read, Write, Edit, and folder-selection prompts such as request_cowork_directory quietly wait for a human to approve them — even when no human is watching. In an unattended schedule, that wait never ends.
I run daily content automation across four sites with Claude scheduled tasks, and over the past month I've settled on three implementation patterns that make permission-dialog stalls impossible by construction. They apply to Claude Code skills, but the same thinking carries over to Cowork mode and the Claude Agent SDK.
Why "stalls in unattended runs" is the worst kind of failure
Permission-dialog stalls leave no traces. There's no stack trace, no timeout, no error message — the task simply looks like it's still running. You usually notice days later when you wonder why a deliverable hasn't shown up.
Three things make this failure mode especially dangerous:
- It never reproduces in manual runs, because you click "OK" on the dialog without thinking about it.
- It generates no telemetry, so you can't bisect with logs.
- Each individual tool call looks correct in isolation, so code review won't catch it.
In my case, a single SKILL.md that used the Read tool instead of cat $FILE froze the schedule for 24 hours. I spent half a month blaming the VM before realising the problem was the tool choice itself.
The design rule for unattended skills is simple: never call a tool that can ask a human anything. The three patterns below are concrete ways to enforce it.
Pattern 1: Route every file operation through bash cat / sed
Claude Code's file tools (Read, Write, Edit) are great in interactive sessions, but treacherous in unattended ones. They run permission checks under the hood, and any path that hasn't been pre-approved triggers a dialog the moment it's touched.
For unattended skills, push every file operation into bash. Here's the rewrite I do across all my SKILL.md files:
# Bad (can stall in scheduled runs)
Step 2: Use the Read tool to load content/config.json,
check the value, then update with the Write tool.
# Good (everything stays in bash)
Step 2: Run the following in bash:
cat content/config.json | jq '.version'
# Use heredoc for full rewrites
cat << 'EOF' > content/config.json
{
"version": "1.2.3",
"updated_at": "2026-05-09"
}
EOF
# Use sed -i for partial edits
sed -i 's/"version": ".*"/"version": "1.2.4"/' content/config.jsonThe << 'EOF' heredoc with single-quoted delimiter suppresses shell expansion, which makes it safe even for files containing backticks or dollar signs. I use it for config files, MDX, and HTML templates.
For partial edits, sed -i covers most cases. When Claude Code generates skill examples, it tends to reach for the Edit tool — for unattended use, swap every one of those calls to sed or awk. If the substitution is too complex for sed, a short python3 -c "..." snippet works just as well.
A useful side benefit of bash-only file work is that the resulting skill becomes portable. Anything you write in cat, sed, or awk runs identically inside a Docker container, on a CI worker, or in a remote server you SSH into. The moment a skill depends on Claude Code's built-in tools, it stops being usable outside the Claude environment. Choosing the lower-level building blocks keeps your automation transferable.
If you've ever had a skill silently fail to fire, the diagnosis steps in Skills not triggering in Claude Code: a checklist are a good starting point — but unattended-mode failures need the additional discipline above.
Pattern 2: Detect paths dynamically on every run
The second landmine in unattended execution is hardcoded paths. Each scheduled run lives in a fresh session, and workspace mount points can change between runs. A path that worked yesterday may not exist today.
Every one of my SKILL.md files starts with a single line that detects the workspace dynamically:
# Detect workspace each run (mount path varies per session)
WS="$(ls -d /sessions/*/mnt/Workspace\ Name 2>/dev/null | head -1)"
if [ -z "$WS" ]; then
echo "Workspace not mounted"
exit 1
fi
# Now everything below can use $WS safely
cat "${WS}/_config/settings.json"Calling request_cowork_directory (or any similar folder-picker tool) opens a selection dialog. In unattended mode, that dialog hangs forever. Switching to dynamic detection means: if the workspace is mounted, the script proceeds; if not, it fails loudly and immediately. You convert "silent freeze" into "explicit failure", which is dramatically easier to operate.
The single biggest lesson from six months of running this stuff is: a loud failure is always better than a quiet stall.
The dynamic-detection pattern also helps when you have multiple workspaces. I keep separate mount points for each of the four sites I run, and using a parameterised lookup (Workspace\ Name becomes a variable) means one SKILL.md can serve all of them. The only thing that changes per site is the workspace name string passed in. That single discipline saved me from cloning the same skill four times with subtly diverging copies.
The same idea applies to Git work. I clone with --depth 1 into a fixed path under /tmp each time, and check df for free space before writing. The general posture for running Claude Code in CI overlaps with what I covered in running Claude Code headless with --bare.
Pattern 3: Build recovery that never asks the human
The third pattern is error handling. The single worst thing an unattended skill can do is ask "should I proceed?" when something goes wrong. Tools like Cowork's AskUserQuestion are wonderful in interactive mode and forbidden in scheduled mode.
My SKILL.md files spell out the recovery contract explicitly:
## Auto-recovery rules
- Bash command failed → try a different bash command that
reaches the same goal (e.g., `git push` fails → `git pull
--rebase` then retry).
- Repository corrupted → `rm -rf $WORK_DIR` and re-clone.
- Disk full → drop caches in stages; if still insufficient,
log the failure and exit.
- Never write "next steps" or "please run manually" — nobody
is reading the output.
If recovery is impossible, log Status: FAILED and exit. Never
ask the user, request approval, or pause for confirmation.When this contract is at the top of the SKILL.md, Claude consistently picks "no human in the loop" whenever it has to make a judgement call. Without it, you sometimes get "let me confirm before proceeding" — which is exactly the freeze you were trying to avoid.
I usually layer two recovery paths: try the primary fix, fall back to a more drastic one (like recreating the working directory), then write Status: FAILED to a log file and exit. For my content pipeline, git push failures cascade through three steps — rebase-and-retry, force re-clone, then give up — and after a month of operation only a handful of failures have ever made it to the third step.
One detail worth highlighting: log the recovery attempts as you go, not just the final outcome. When my pipeline fails, I want to know whether it failed on the first try and recovered cleanly, or whether it cycled through every fallback. A simple counter (RETRY=$((RETRY+1))) and a one-line log entry per attempt is enough. Without that visibility, you cannot tell whether your recovery layers are doing real work or quietly papering over a deeper problem.
If you want fine-grained control on top of this, the permissions block in .claude/settings.json lets you put Read, Write, and Edit on the deny list for unattended skills, so any accidental call fails loudly. I covered the full mechanism in Claude Code permission modes for production security.
Always validate with one real scheduled run
One last piece of advice. Whenever you ship a new SKILL.md change, do not declare it done after a successful manual run. Permission-dialog bugs cannot reproduce locally, so the only valid test is an actual scheduled invocation.
My validation flow is:
- Update the SKILL.md and run once manually to verify basic behaviour.
- Set the scheduled task's "next run" to five minutes from now.
- Wait, then read the execution log to confirm no dialog-related stall.
- If clean, restore the normal cadence.
Since I started doing this, "the task froze overnight" has dropped to almost zero. Local testing gives almost no signal about unattended behaviour.
Try rewriting one line in your SKILL.md today
If you already have a skill in production, open its SKILL.md and replace one mention of Read or Write with cat or cat << 'EOF' > file. That single change usually makes the difference you'll feel within a day. For skills you want to grow into bigger workflows, Claude Code Skill Composition goes deeper into the structural side.
Unattended automation is invisible work — nobody notices when it's running well. That's exactly why investing once in a stall-proof design pays back every quiet morning that follows. Thanks for reading.