From Python
from lucen import Hub — the CLI's own push, pull, train, follow and sim-gate engine as a small typed Python API for scripts and notebooks; every example on the page is run against a hub by a test.
Use it from Python. from lucen import Hub is the lucen CLI's own engine —
push, pull, train and follow, the sim gate, the robot commands — behind a small
typed API for a training script or a notebook, the way huggingface_hub gives
you snapshot_download. Nothing on this page talks to the hub on its own:
every method is the function the command line calls, with the same arguments,
so a script and a terminal cannot disagree about what a push uploads or what a
404 means.
Every ```python block on this page is executed, in order, against a real hub
by packages/cli/tests/test_python_api_docs.py, and the block under it is
what it printed (… stands for a value that differs per hub — an id, a byte
count). Where an example names this hub, it is printed with the address of
the hub serving this page: https://api.mousemouse.ai for the API, https://mousemouse.ai for
the web app.
Install
The module ships inside the lucen-cli distribution — there is no separate
package to install, and no lucen on PyPI (if one turns up there, it is not
ours). Install it into the environment your script runs in; the rollout
extra adds MuJoCo and onnxruntime, which the sim gate and hub.robots need
and nothing else does:
uv pip install --index lucen=https://api.mousemouse.ai/install/simple/ "lucen-cli[rollout]"
import lucen
print(lucen.__version__)
0.1.0
Hub()
Hub() resolves the hub and the key exactly as the CLI does: LUCEN_API_URL
and LUCEN_API_KEY first, then ~/.config/lucen/credentials — the file
lucen auth login writes at mode 0600, refused if another user can read it.
Nothing is built in: with no address anywhere, Hub() raises and says how to
set one rather than sending your key to a guess. Hub(url=…, key=…) overrides
either, for a notebook that holds its key in its own secret store; a key is
never read from a file the module did not document or an argument you did not
pass.
from lucen import Hub
hub = Hub()
me = hub.me()
print(me.username, me.scopes)
you ('read', 'write', 'train')
Scopes nest (read ⊂ write ⊂ train) and the hub enforces them: a call
the key may not make raises lucen.Forbidden naming the scope it needed (see
Errors). The records every call returns are frozen dataclasses —
ids, URLs and counts as attributes, to_dict() for the object lucen … --json
would have printed, raw for the whole SDK model when you need a field the
record does not carry.
1 · Pull a robot
Every file is hashed while it streams and takes its name only when the sha256 matches the tree's; a second pull into the same directory downloads nothing. A public repo needs no key.
pulled = hub.pull("lucen/laika", "./laika")
print(pulled)
print(pulled.files_downloaded, "downloaded,", pulled.files_skipped, "already local")
Pulled lucen/laika: … file(s), … downloaded (…), 0 already local
… downloaded, 0 already local
2 · Push a dataset
hub.push creates the repo when it does not exist (kind and public apply
then and only then) and uploads what the hub does not already hold. Blobs are
content-addressed: pull lucen/laika-demo and push it back as your own, and
zero bytes move — the hub hashed those bytes itself, so your push links
them across accounts instead of re-uploading them. Change one file and only
that file uploads; kill a push mid-transfer and the next one sends exactly the
parts that never landed.
hub.pull("lucen/laika-demo", "./laika-demo")
pushed = hub.push("./laika-demo", "you/laika-demo", kind="dataset", public=True)
print(pushed)
print(pushed.url)
again = hub.push("./laika-demo", "you/laika-demo")
print(again.files_uploaded, "uploaded,", again.files_unchanged, "unchanged")
Created and pushed you/laika-demo: … file(s), 0 uploaded (0 B), … deduped, 0 unchanged
https://mousemouse.ai/you/laika-demo
0 uploaded, … unchanged
hub.repos.list(kind="dataset", owner="you"), .get("you/laika-demo"),
.create(…) and .delete(…) are the rest of what a script does with repos;
hub.datasets.episodes("you/laika-demo") reads the episode index the viewer
shows.
3 · Train, and follow it
A run executes on a runner you start — free, because the hardware is yours. In a second terminal, with the same credentials:
lucen worker run
Then submit a recipe with follow=True: the call stays until the run ends and
returns its final state. Every log line reaches on_log as a LogEvent
(event.text is the line as lucen train --follow prints it: | for the
trainer's own output, ! for a notice the hub wrote into the log). The
built-in stub_ppo template trains nothing — a synthetic curve and a stamped
ONNX in about a second — but walks the whole road: claim, stream, publish,
report. robot names the robot the policy is for: the runner reads its card
so the published ONNX carries that robot's joint order, which is what the sim
gate checks before it runs anything.
run = hub.runs.create(
{"template": "stub_ppo", "robot": "lucen/laika", "hyperparams": {"iterations": 40}},
follow=True,
on_log=lambda event: print(event.text) if event.kind == "log" else None,
)
print(run.status, run.output_model_repo)
…
| [lucen-stub-ppo] resolved config written; 40 iterations, seed 0
| iter 1 reward …
…
| iter 40 reward …
…
| [lucen-stub-ppo] done
…
succeeded you/stub-ppo-…
Without follow the run is returned queued at once; hub.runs.logs(id, follow=True) streams it later, hub.runs.get(id) reads its state,
hub.runs.cancel(id) stops it (a queued run ends now, a running one is
stopped by its runner within seconds), and hub.runs.estimate(recipe) /
.recommend(recipe) price and plan a recipe before anything is created. The
recipe may also be the path of the YAML file lucen train -f reads. Ctrl-C
during a follow detaches; the run keeps going.
4 · Gate it in sim
The sim gate runs the published policy in MuJoCo against the robot's own MJCF
and meshes, one command cell at a time, and the hub scores it: a cell
PASSes when every seed stays upright for the whole horizon and every commanded
axis tracks inside [0.85, 1.15] of the command. With local=True this machine
claims and executes the rollout (the rollout extra); run= attaches the
scorecard to the training run. A local rollout that did not succeed raises
HubError with the reason, as lucen rollout exits 1.
rollout = hub.rollouts.create(
run.output_model_repo, "lucen/laika", battery="smoke", local=True, run=run.id
)
print(rollout)
for cell in rollout.cells:
print(f"{cell.name:<9} {cell.verdict} {cell.alive}/{cell.seeds} upright")
rollout_… (succeeded): you/stub-ppo-… on lucen/laika, battery smoke — 1/5 cells PASS
stand PASS 2/2 upright
vx+0.30 FAIL 2/2 upright
vy+0.10 FAIL 2/2 upright
vy-0.10 FAIL 2/2 upright
yaw+0.50 FAIL 2/2 upright
The stub holds its default pose: it stands, and it tracks no command — which
is what the gate is for. Without local=True the rollout waits queued for a
lucen rollout worker on any machine with the extra installed, and
hub.rollouts.get(id) reads it back once that worker reported.
5 · Read the scorecard
rollout.scorecard is the hub's verdict per cell; because the rollout named
the run, the run now carries the same count as its gate, and the Training
Coach lists every PASS cell as a standing constraint on anything it proposes
about that run. hub.open(…) is the page's address on the web app (the origin
the hub names, remembered the way lucen open remembers it).
print(rollout.scorecard)
print(hub.runs.get(run.id).gate)
print(hub.open(run.id))
{'stand': 'PASS', 'vx+0.30': 'FAIL', 'vy+0.10': 'FAIL', 'vy-0.10': 'FAIL', 'yaw+0.50': 'FAIL'}
1 / 5 PASS
https://mousemouse.ai/runs/run_…
Errors
Every refusal is one exception family, lucen.HubError, carrying the three
lines a terminal user reads: message (what happened), detail (what the hub
said, when it said anything) and fix (one line that makes the next attempt
succeed). The subclass follows the status: NotSignedIn (401, or no key at
all), Forbidden (403 — the fix names the scope), NotFound (404: absent, or
private and not yours; the hub never tells those apart), Conflict (409) and
HubUnreachable (no answer, or a 502/504/52x from the network in front). A
local refusal — a directory with no files, a reference that is not
owner/slug — is a plain HubError.
from lucen import NotFound
try:
hub.repos.get("lucen/no-such-robot")
except NotFound as error:
print(error.status, "-", error.message)
print("Fix:", error.fix)
404 - Reading lucen/no-such-robot: no such repo, or not yours.
Fix: `hub.repos.list()` shows the ones this credential can see; a private one you are not a member of looks the same as none.
Progress
Silent by default: the record is the whole answer. Hub(progress=True) prints
exactly what the CLI prints on stderr — a bar on a terminal, one line per
decile in a log. Hub(on_progress=callback) hands every milestone and byte
count to a function of yours as a ProgressEvent, for a notebook widget;
followed log lines arrive the same way when no on_log is given.
events = []
Hub(on_progress=events.append).pull("lucen/laika", "./laika-again")
print(events[0].kind, "-", events[0].message)
print(events[-1].kind, "-", events[-1].label, f"{events[-1].fraction:.0%}")
step - Downloading … file(s), … (0 already local)
bytes - Downloading 100%
What a script cannot do
The same two things no API key can do: mint a key (/v1/keys is
session-only, so a leaked write key cannot promote itself) and approve a
policy onto a robot (an agent or a script may request one; a signed-in
person decides in the Inbox). The robot's own side is the
lucen-device binary. hub.robots.init | validate | publish are
Add your robot's three commands as methods, and
hub.orgs is lucen org; both need the same scopes the commands do.
The surface
Hub(url=None, key=None, *, progress=False, on_progress=None, timeout=60.0)
.me() -> Me .open(repo_or_id=None) -> str
.push(path, repo, *, kind="dataset", public=False, rehash=False, description=None,
jobs=8, dry_run=False, on_progress=None) -> PushResult
.pull(repo, path=None, *, prefix=None, force=False, jobs=8, on_progress=None) -> PullResult
.repos.list(*, kind=None, owner=None, q=None, limit=20) -> list[Repo]
.get(repo) -> Repo · .create(repo, *, kind="dataset", public=False, description=None, tags=()) -> Repo
.delete(repo) -> None
.runs.create(recipe, *, executor=None, robot=None, parent=None, owner=None, idempotency_key=None,
follow=False, on_log=None) -> Run
.list(*, status=None, executor=None, owner=None, limit=20) -> list[Run] · .get(id) -> Run
.logs(id, *, follow=False, tail=None) -> Iterator[LogEvent] · .cancel(id) -> Run
.estimate(recipe) -> RunEstimate · .recommend(recipe) -> RunRecommendation
.rollouts.create(model, robot, *, battery="smoke", seed=0, seeds=None, horizon_s=None, run=None,
conditions=None, checkpoint=None, executor="worker", local=False, name=None,
work_dir=None, video=True, keep=False, on_progress=None) -> Rollout
.get(id) -> Rollout · .list(*, status=None, model=None, run=None, limit=20) · .cancel(id)
.robots.init(path, *, control_hz, action, base=None, kp=None, kd=None, …) -> RobotDraft
.validate(directory=".", *, card=None, settle_s=3.0, dynamics=True) -> RobotReport
.publish(repo, directory=".", *, public=False, description=None, force=False, …) -> RobotPublishResult
.card(repo) -> dict
.orgs.list() · .create(slug, *, name=None, description=None) · .members(org)
.add(org, username, *, role="member") · .remove(org, username) · .leave(org)
.datasets.episodes(repo) -> list[Episode]
Next
- From a terminal — the same path as commands, every one run verbatim.
- MCP setup — the same engine for an agent, through a scoped key.
- API reference — the contract every surface is a copy of.