Skip to content
Docs menu

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.