---
title: 'Mullet from Python'
canonical: 'https://www.dealmobile.co.uk/writing/mullet-from-python'
url: 'https://www.dealmobile.co.uk/writing/mullet-from-python'
type: Post
published: '2026-10-26'
summary: '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 from Python

Published 2026-10-26

![Mullet from Python. The line from mullet import api, then five calls in order, each with what it does: api.plan(goal, repo) leaves a task tree awaiting approval; api.approve(job); api.run(job) runs and waits until done, blocked or paused; api.report(job) gives the Markdown report; api.merge(job) adds a new branch to your repo.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/mullet-from-python/hero.png.webp?itok=qvpwp31M)

Mullet is for [coding jobs you can leave running](https://www.dealmobile.co.uk/writing/mullet-2-coding-jobs-you-can-leave-running.md). 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:

```python
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](https://www.dealmobile.co.uk/writing/every-session-starts-with-one-question.md), 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](https://www.dealmobile.co.uk/writing/a-task-plan-that-knows-what-depends-on-what.md)). `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>.

- **Projects:** [Mullet](https://www.dealmobile.co.uk/projects/mullet.md)
- **Tags:** [Python](https://www.dealmobile.co.uk/writing/tag/python.md), [AI](https://www.dealmobile.co.uk/writing/tag/ai.md), [Mullet](https://www.dealmobile.co.uk/writing/tag/mullet.md)

---

Index of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)
