Skip to main content

tooling

The five-hour window, and the report nobody was there to start

A scheduling daemon for headless Claude Code sessions, managed through MCP because the users were not engineers and I was not building a GUI.

12 MCP tools · 0 lines of UI

Situation
Engineers on Claude Code subscriptions were losing capacity to the clock. The usage window is five hours long and it starts on your first prompt of the day, so a team that all sits down at nine is all locked out at two, in the middle of the afternoon.
Constraint
Anything that fixed it had to run with no terminal attached, which meant no interactive permission prompt, no TTY and, on macOS, no access to the credentials the interactive client had saved. It also had to be usable by producers and project managers, not only by the people who would happily hand-edit a TOML file.
Decision
I wrote blanket: a Go daemon that schedules Claude Code sessions on cron expressions and one-off delays. It drives Claude over the Agent Client Protocol rather than shelling out to `claude -p`, so the daemon itself answers the permission requests, and it exposes its whole management surface as an MCP server rather than a web UI.
Tradeoff
Making MCP the interface meant the interface is only as good as the model in front of it, and it has no authentication of its own: the trust boundary is the operating system's, so anyone who can run the binary as you can schedule anything, anywhere.
Result
Sessions fire at six in the morning. The window opens before anyone sits down and closes at eleven, and the morning report is finished and waiting instead of being something you start and then watch. Twelve MCP tools cover the entire CLI surface, and the GUI on the todo list never had to be written.

Two problems that turned out to be one problem

The first problem was arithmetic. A Claude Code subscription meters usage in a rolling five-hour window that opens on your first prompt. Nobody chooses when that window opens; the window opens when you happen to start working. A team that starts at nine is out of capacity at two, which is the worst possible time, because two in the afternoon is when you are deep in something and least able to stop.

The second problem looked unrelated. We had a set of recurring jobs that were obvious candidates for an agent: summarize what landed in the repository overnight, check the issue tracker, produce a status digest before standup. Every one of them required a person to open a terminal, paste a prompt and then sit there while it ran. The work was automatable but the trigger was not, so in practice the jobs did not happen, or happened at eleven when someone remembered.

They are the same problem. Both are about when the first prompt of the day gets sent, and in both cases the answer is that a person should not be the one sending it. If a scheduler sends it at six, the window opens at six and closes at eleven, and the report that used to eat the first half hour of the day is already sitting there. You arrive to the analysis rather than to the prompt.

Why not cron

The honest first question is why this is not four lines in a crontab. claude -p "..." in a cron entry does technically run.

It falls apart on everything around the prompt. Cron gives you no record of what happened beyond whatever landed in a mail spool, no way to ask whether the daemon is alive, no way to keep a slow run from overlapping the next one, and no answer at all for the permission prompt an agent hits the moment it wants to write a file. It also gives you no way for a producer to schedule anything, because the interface is crontab.

So the requirements were narrower than “run a command on a schedule”: run an agent headlessly, decide in advance what it is allowed to do, keep the output, and let a non-engineer manage the whole thing.

Building it on a kernel

Blanket is Go, built on nullspace, a small application framework I use for this kind of tool. It gives me module lifecycle (init, start, stop in registration order), TOML configuration with environment overrides, a typed service locator and structured logging, so the code in blanket is about scheduling and nothing else.

Three modules register in main.go: logging, an optional SQLite module for output storage, and the scheduler. The scheduler validates every session at init, registers a scheduler.runner resource, and only touches cron at start. That split matters more than it sounds: blanket run initializes the kernel and calls the runner directly without ever starting the cron loop, so testing a session is the same code path as running one on schedule, minus the clock.

For a tool that fires a handful of cron jobs this is arguably more structure than the job needs. I took the bet knowingly. The thing has since grown SQLite output, a Slack notifier, project registration and an MCP server, and each of those went in as a module rather than as another branch in a main function that was already too long.

Scheduling: cron in memory, at-jobs on disk

Recurring sessions live in blanket.toml as cron expressions and go into an in-memory cron scheduler at start.

One-off jobs work differently. blanket at 2h -p "..." does not talk to the daemon at all. It writes a JSON file into a spool directory with an absolute run time and exits; it never boots the kernel. The daemon scans that directory every thirty seconds, picks up anything new, and arms a time.AfterFunc for it. Writes are temp-file-plus-rename, so the scanner can never read a half-written job.

Using the filesystem as the channel between the CLI and the daemon means there is no IPC, no socket, no port and no daemon dependency in the at command. It also means jobs survive a daemon restart for free: pending timers are cancelled on shutdown, the files are left alone, and anything whose time passed while the daemon was down runs immediately on the next start. That last behavior is deliberate. A missed morning report should still show up, late, rather than silently not exist.

Two smaller pieces came from watching it run. Sessions do not overlap themselves by default, because a cron entry that fires while its previous run is still going used to give you two agents in one working directory; the second run is now skipped and logged as a warning, with allow_overlap for people who want the old behavior. And the daemon touches a health file on every scan tick, so blanket status can distinguish “the process exists” from “the process is actually scanning”, and reports healthy, STALE or unknown rather than a PID and a shrug.

ACP instead of claude -p

The first version shelled out: claude -p <prompt> --permission-mode acceptEdits. It worked, and it was the wrong shape for two reasons.

The permission model was all-or-nothing. acceptEdits is a single switch that says yes to everything an agent might want to do, and a session that should be allowed to read a repository and write a summary should not, by the same token, be allowed to run arbitrary commands.

The second reason is that a subprocess writing to stdout is not a protocol. There is no way to tell an agent’s prose apart from a failed tool call, no structured stop reason, and nothing to do with the output except capture the whole stream.

So the session package now speaks the Agent Client Protocol over JSON-RPC on stdio, launching @agentclientprotocol/claude-agent-acp as a subprocess. Blanket implements the client side of ACP, which changes the relationship: the agent asks for permission and blanket answers, per tool call, in code.

That is where the permission model lives now. Two orthogonal flags, both defaulting to off: allow_edits gates the edit, delete and move tool kinds, and allow_network gates the fetch kind, meaning WebFetch and WebSearch. Execute is always rejected, with no flag to turn it on. A session can be read-only with network access, or allowed to edit with no network at all, and those are genuinely different risk profiles for an unattended process. Being a real protocol client also gets structured session updates, so agent text goes to stdout and failed tool calls go to stderr, and stop reasons (end_turn, max_tokens, refusal, cancelled) map to process-style exit codes that the scheduler can log and store.

The subprocess environment is an allowlist, not an inheritance. Cancellation closes the adapter’s stdin and gives it five seconds before SIGKILL, which is symmetric with the daemon’s own shutdown grace period, so stopping the daemon does not orphan a running agent.

Everything unattended breaks somewhere new

Two failures were worth the trouble they caused, both of them macOS.

The first was authentication. Claude Code stores its OAuth credentials in the Keychain with an ACL scoped to the claude binary itself. The ACP adapter is a Node process, and it is not on that ACL, so every session launched by a launchd-supervised daemon failed with Authentication required while the same session run by hand from a terminal worked perfectly. That is the worst class of bug: it only reproduces in the environment you cannot attach a debugger to. The fix is blanket auth login, which saves a long-lived token from claude setup-token into a file blanket owns at mode 0600 and injects it into the adapter as an environment variable, bypassing the Keychain ACL rather than fighting it. There is an integration test that re-execs the test binary as a fake ACP adapter and asserts the token actually arrived, because the first fix for this shipped pointing at a no-op stub and looked fine.

The second was macOS TCC, the permission system behind “this app wants to access your Documents folder”. A launchd-spawned daemon has its own responsibility scope, separate from the terminal that installed the service, so the first time it touched a protected path the OS raised a dialog that nobody was there to see. The session hung. Now every command that can eventually start a session reads each configured directory at startup, which makes the dialog fire while the user is still sitting at the terminal running blanket service start. Denied paths print a hint pointing at the right System Settings pane, and missing directories surface as a warning, which catches config typos at start rather than at nine the next morning.

Neither of these is interesting engineering. Both of them are the entire difference between a tool that works and a tool that works when you are watching.

The UI I did not build

This was an internal tool. It had to be usable by producers and project managers, and I did not want to spend a week on a web front end for a scheduler that a dozen people would touch.

So the management surface is an MCP server. blanket mcp runs as a stdio subprocess of whatever LLM client the user already has open, and twelve tools cover the whole CLI: schedule_at, schedule_cron, run_now, remove_session, cancel_at_job, list_sessions, list_at_jobs, get_history, get_logs, get_status, plus report_bug and request_feature, which file GitLab issues so bug reports arrive with the session’s exit code and last fifty lines of output already attached. A non-engineer types “run the repo summary at seven every weekday” and the model writes the TOML.

The MCP server deliberately does not boot the kernel. It operates on the same on-disk primitives the CLI uses: spool files, the TOML config, the SQLite database, the PID file, the log. The kernel is built for a long-running process, and an MCP server is a short-lived request/response one that starts when a client connects and dies when it disconnects. Keeping them apart means the MCP server has no module lifecycle to wait on and no way to interfere with a running daemon’s state beyond the files they share. When a tool writes config it sends the daemon a SIGHUP, which triggers a diffed reload of the session list, adding, removing and replacing individual cron entries rather than restarting the scheduler.

That reload path also produced my favorite bug in the project. The config structs carried only json tags, because the nullspace kernel decodes config through a JSON round-trip and that had always worked. The SIGHUP path unmarshals TOML directly. So every hot reload silently dropped allow_edits and allow_overlap and reset them to false. Nothing errored. The sessions just quietly became less capable than their config said they were, and only after a reload. Matching toml tags and a regression test that asserts both flags survive a round-trip.

What it cost

Making MCP the interface is a real tradeoff and I would not pretend otherwise. The interface is only as good as the model driving it, error messages have to be written for a reader who is not looking at a terminal, and there is no way to hand someone a URL. What I got in exchange is that the management layer was a day of work instead of a fortnight, and it inherited a UI that people already had open.

The security position is the sharper cost. The MCP server has no authentication of its own. Stdio transport means the trust boundary is the operating system: anything that can fork-exec the binary as your user can call every tool, and schedule_at and run_now launch agents in arbitrary directories. That is defensible for a desktop client launching a local subprocess and it does not generalize one step further; the moment it is exposed over any network transport it needs an auth layer in front of it. That is written into the architecture docs rather than left for someone to discover, and a security audit of the execution path is still open on the todo list. An unattended process that writes files on a schedule deserves one.

The last cost is conceptual, and it is the one worth taking seriously. Scheduling an agent means giving up the review step that happens naturally when you are sitting there watching it work. That is exactly why the permission flags default to off, why execute is unconditionally rejected, and why every session’s output is retained and queryable. The point was never to have an agent working while nobody is looking. It was to move the waiting off the front of your day, so that what is in front of you when you sit down is a result you can judge.