Mullet from Python
Mullet 2.3.0 adds mullet.api, so a script or CI can plan, approve, run and merge coding jobs without the CLI. It was built by splitting the CLI’s own code, so the same checks and limits apply and every error is one kind.
Mullet is for coding jobs you can leave running. Something has to start them, and it isn’t always me at a terminal. It might be a CI job, a script that works through a list of chores overnight, or another tool. Until 2.3.0 the only way in was the mullet job CLI, which meant shelling out and reading text written for a person.
That is fragile in the usual ways. A reworded message breaks a parser, and exit code 1 says something went wrong but not what. So 2.3.0 adds mullet.api, a small, documented Python surface for the same job flow.
The flow
This is the example from the module’s own docstring:
from mullet import api
job = api.plan("add a subtract function with a test", repo=".", plan_file="plan.yaml")
api.approve(job)
result = api.run(job) # runs in this process until done, blocked or paused
print(api.report(job))
if result.status == "done" and not result.nodes_unverified:
api.merge(job) # copies the branch into your repo; nothing else changes
plan creates the job and leaves it awaiting approval. With plan_file it uses a task tree I wrote. Without one, a read-only Claude Code session writes the tree, as mullet job plan does. approve is the step a person normally takes after reading the plan. run is also the wait: by default it runs the job in the calling process and returns only when the job is done, blocked or paused. merge copies the finished branch into the repo as a new branch and changes nothing else. It raises Unverified if any step couldn’t be fully checked, unless you pass allow_unverified=True.
A job argument is either the JobHandle that plan returns or a job id string, so one script can pick up a job another planned.
The rest of it
| Call | What it does |
|---|---|
status(job) |
the job’s state now |
report(job) |
the Markdown report |
run(job, detach=True) |
starts the background runner and returns at once |
stop(job) |
asks the runner to stop; the job is paused, not lost |
resume(job, max_spend=None) |
restarts a paused or blocked job in the background, with a new spending cap if you give one |
list_jobs(limit=20) |
recent jobs, newest first |
Results are frozen dataclasses of plain values: the id, status, goal and branch, the counts of steps passed, unverified and blocked, the dollars spent and any error. The status result also says whether a runner is alive. Nothing you get back holds a connection or needs closing.
One kind of error
Every error is a mullet.errors.MulletError, and those about jobs are JobError subclasses: JobNotFound, NotApproved, NotRunnable, RunnerBusy, Unverified, SpendLimit, StrategyOff, PlanFailed and Unavailable. mullet.api re-exports them. A script can catch MulletError for “Mullet refused, and here is why”, and let anything else surface as the bug it is. The messages are the CLI’s own.
How it was built
I didn’t write a second version of the job flow. I split the CLI’s commands into the part that does the work and the part that prints. cmd_plan now fills in a PlanOptions and calls create_plan. cmd_run calls prepare_run and then start_runner. cmd_merge calls merge_job. Each of those functions takes a say callback for its messages: the CLI passes one that prints, and the API passes one that does nothing.
The CLI’s own error, CliError, is now simply another name for JobError. The CLI catches it at the top, prints the message and exits 1. The API lets it through. Same classes, same words.
The CLI is unchanged on top, and that is the point. The API gets the same checks, the session’s budget and delegation choice from every session starts with one question, and the shared spending envelope, because there is only one place each of them is applied. A side door into a tool for unattended work is only safe if it can’t skip anything the front door checks. StrategyOff exists because of that: if the session’s choice is to delegate nothing, plan refuses from Python exactly as it does from the terminal.
What is stable
Only the names in mullet.api.__all__. Everything else in Mullet, mullet.cli and mullet.jobs included, may change, and I’d rather say so now than break someone’s script later. docs/API_PYTHON.md lists the functions, their signatures and the errors.
What it doesn’t do
There is no separate wait call. run waits unless you detach it, and a detached job is followed with status.
The handle plan returns carries the planner’s notes, but not the open questions a plan can now carry (a task plan that knows what depends on what). mullet job show lists those. That matters because of the next point.
approve is one line, so a script that plans and approves in one go has decided not to read the plan. For a plan file I wrote myself, that’s fine. For a plan Claude wrote, approving is the one step I’d keep for a person, and the API leaves that choice to whoever writes the script.
This is in Mullet 2.3.0. The code is at https://github.com/tomgould/mullet-public.
More on Mullet
-
Cheap models only for the work that is safe to be wrong about
Mullet tries free and cheap models first only on steps its gates can judge, and keeps risky and one-way steps for Claude and its reviewer. Why…
-
Decision records for a solo project
Mullet now keeps eleven short decision records beside its code, each a context, a decision and its consequences. Why a one-person project bothers…
-
A container backend stricter than the usual Docker recipe
Mullet 2.3.0 can run a job’s commands in Docker: one throwaway container per command, no network, no capabilities, secrets on stdin and a lifeline…
-
Putting the sandbox behind an interface
Mullet’s bubblewrap sandbox now sits behind a backend interface, with contract tests every backend must pass. Before the change went in, 15,552…
More writing
-
A Markdown version of every page, for AI agents
Why every page on this site now has a Markdown version for coding agents, the three ways an agent finds it, what goes into it, and how it stays out…
-
A TV remote from the command line
Pairing a Linux computer with a Google TV over the protocol phone remotes use, what the six-character code on the screen really checks, and why a…
-
The packages I kept on a Google TV
The other half of decluttering a TCL Google TV: a keep-list of 35 packages that debloat lists remove or that only look disposable, how each one got…
-
Letting an agent drive the TV
The rules a coding agent worked under while it changed a TV we were still watching: only changes that can be undone, small batches with a person…