Plugins
Coxswain's core repository names no vendor. Anything that talks to a particular tracker, forge, model vendor or hosted service is a plugin: a Python package that registers under an entry-point group, which core looks up by name. The public coxswain-plugins repository holds the ones Coxswain ships.
The groups
| Group | Loaded by | Chosen by | Built in |
|---|---|---|---|
coxswain.sources |
cox route pull |
--source <name>, configured under the profile's sources.<name>; a bare pull reads every configured source |
github (issues labelled intake and PRs labelled review, through gh) and folder |
coxswain.forges |
cox runs land |
the profile's forge: key |
local (plain git, the default), github, forgejo, and auto (each repository's forge, from its origin) |
coxswain.trackers |
cox route sync |
the profile's tracker: key |
none (the default) and github-projects |
coxswain.system_one |
the harness's fast path | the provider profile's system_one.backend |
model-tier |
coxswain.runners |
the harness | the provider profile's runner: key |
claude-code and anthropic |
coxswain.storage |
the harness's trace store | the URL's scheme | file and s3 (built in) |
A name that is neither built in nor registered is refused with a line naming the group, except in system one: an unregistered backend turns system one off for that run, and the run goes on without it.
Built-in sources
Both run locally: github through your own gh login, folder on the
filesystem. Name them in the routing profile, and map each listed repo
or folder to the repository its tickets target:
sources: {"github": {"repos": ["owner/repo"]}, "folder": {"repos": ["~/intake"]}}
repo_map: {"owner/repo": "/path/to/repo", "~/intake": "/path/to/repo"}
- github files each issue labelled
intake(filter: label:<name>picks another label), then swaps the label forintake:takenand comments the intake path. An open PR labelledreviewis reviewed instead, and relabelledreview:taken. - folder files each
*.mddirectly in the folder: its first# headingis the title (else the file name), the rest the body. A filed file moves into the folder'sdone/, so the folder holds only what is waiting. The same name with new text files again.
The autonomous chair runs a bare cox route pull when it has free lanes
and nothing else queued, so it reads every configured source.
Installing
cox loads sources, forges and trackers from its own environment. cox is
installed editable from the coxswain checkout, so add the plugins to that
install:
uv tool install --force --editable ~/.local/share/coxswain/installs/coxswain \
--with "coxswain-plugins @ git+https://github.com/ppfenning/coxswain-plugins"
The harness loads system-one backends and runners from the checkout's venv:
uv pip install --python ~/.local/share/coxswain/installs/coxswain/.venv/bin/python \
"coxswain-plugins[jev] @ git+https://github.com/ppfenning/coxswain-plugins"
cox upgrade reinstalls cox with only the extras it had, which drops the
--with package, so run the first command again after an upgrade. Its venv
sync keeps packages the lock does not name, so the second survives.
A plugin that needs a vendor's client library declares it as an extra,
as jev does. Installing one plugin never pulls in another's
dependencies. cox setup doctor lists what each side can load in its
plugins row.
Writing one
Register the entry point in your package's pyproject.toml:
[project.entry-points."coxswain.forges"]
myforge = "my_package.forge"
Each group expects a different shape:
- Source (a module):
candidates(config, listing) -> tuple[Ref, ...],read(raw) -> Candidate,taken(link, intake_links) -> boolandmark_argv(ref, intake_path) -> list[str]. The profile'ssources.<name>block givesrepos,filterandtoken_env. An adapter reads a tracker and files intake tickets. It never moves the tracker's state. - Forge (a module):
find_open_prs(repo, branch),push(repo, branch),open_pr(repo, title, body, *, head=None, base=None),wait_checks(repo, timeout_s, *, ref="HEAD")andmerge(repo, step). Each except the first returns(ok, detail).mergeupdates the local default branch without checking anything out. - Tracker (a module):
sync(workspace, items, *, dry_run) -> (ok, lines).cox route syncreads the work store's items, applies--itemif given, and hands them over.itemsare the work store's initiatives and tasks: each has an id, title, body, repo, state, phase, run, cost, gate, issue and parent. Returnokand the lines to print, and write nothing whendry_runis true. An exception is reported as the tracker failing, androute syncexits 1. - System-one backend (a callable):
make(model, api_key, block)returns a decider whosedecide(question, state)returns an answer with a confidence. Setmake.needs_key = Truefor a hosted service. Its key then comes only from the environment variable named insystem_one.key_env, never from the provider's own key. Setmake.wants_runner = Trueto be handed the run's runner asrunner=. A backend runs in shadow untilcox stats system-onesays it has graduated. - Runner (a callable):
factory(profile, *, role_skills, workdir, repo)returns a runner whoserun(*, role, tier, schema, prompt, ...)returns the node's structured result.