# Nodus for Modal users

> Move a Modal app to Nodus. The Python SDK keeps Modal's names wherever the concept matches, so most code changes by one import.

Source: https://nodus-platform-site.pages.dev/docs/getting-started/modal-users/
Build revision: 211ad9f836655b1c3a2668c4693e442471f28614

The Nodus Python SDK keeps Modal’s names wherever the concept is the same: `App`, `@app.function`, `.remote()`, `.map()`, `.spawn()`, `@app.cls` with `enter` and `exit` hooks, `Image`, `Volume`, `Secret` and `Sandbox`. Most apps move by changing `import modal` to `import nodus`. This page lists what is the same, what is spelled differently and what Nodus adds.

## Sign in and run

Terminal window

```console
pip install nodus-compute
nodus login                 # opens the console, stores a key for each org you pick
nodus run app.py            # like `modal run`: an ephemeral App, deleted when the entrypoint returns
nodus deploy app.py         # like `modal deploy`: a persistent App
nodus serve app.py          # like `modal serve`: redeploys when a file changes
```

In CI, set `NODUS_API_KEY` instead of running `nodus login`.

examples/python/quickstart/app.py

```python
"""Quickstart: one Function called three ways.

Run it with `nodus run examples/python/quickstart/app.py --n 10`.
"""

import nodus

app = nodus.App("quickstart")


@app.function(cpu=1, memory="1Gi", max_cost=1)
def square(x: int) -> int:
    return x * x


@app.local_entrypoint()
def main(n: int = 10) -> None:
    print("remote:", square.remote(7))  # one call; blocks for the result
    call = square.spawn(8)  # start without waiting
    print("spawned:", call.get(timeout=600))
    print("map:", list(square.map(range(n))))  # one call per input, results in input order
```

## Side by side

|Modal|Nodus|
|-|-|
|`import modal`|`import nodus`|
|`app = modal.App("x")`|`app = nodus.App("x")`|
|`@app.function(gpu="H100", timeout=3600)`|`@app.function(gpu="H100", timeout="1h")` (numbers are also seconds)|
|`f.remote(x)`, `f.map(xs)`, `f.spawn(x)`, `call.get()`|The same|
|`f.local(x)`|The same|
|`@app.local_entrypoint()`|The same|
|`modal.Function.from_name("app", "f")`|`nodus.Function.from_name("app", "f")` (`Function.lookup` also works)|
|`@app.cls()` with `@modal.enter()`, `@modal.method()`, `@modal.exit()`|`@app.cls()` with `@nodus.enter()`, `@nodus.method()`, `@nodus.exit()`|
|`modal.Image.debian_slim().pip_install("torch")`|`nodus.Image.debian_slim().pip_install("torch")`|
|`modal.Image.from_registry(...)`, `.apt_install`, `.run_commands`, `.env`, `.add_local_dir`|The same|
|`modal.Volume.from_name("v", create_if_missing=True)`|The same; `vol.commit()` and `vol.reload()` behave as in Modal|
|`modal.Secret.from_name("hf")`, `Secret.from_dict({...})`|The same, plus `Secret.from_dotenv(".env")`|
|`modal.Sandbox.create(app=app, image=...)`|`nodus.Sandbox.create(image=...)` (no App needed)|
|`sb.exec("python", "-c", "...")`, `p.stdout.read()`, `p.wait()`|The same|
|`sb.open(path, "w")`|The same|
|`sb.tunnels()`|Returns a list of `Tunnel(port, url, public)`; `sb.tunnels.open(8080)` is not available yet|
|`sb.snapshot_filesystem()`|The same (Beta)|
|`@modal.experimental.clustered(size=2)`|`@nodus.clustered(size=2)` (Beta)|
|`min_containers`, `max_containers`, `scaledown_window`|The same, or `min_workers` and `max_workers`|
|`await f.remote.aio(x)`|The same: every blocking call has an `.aio` form|

GPU strings use Modal’s spellings: `"H100"`, `"H100:2"`, `"A100-80GB"`, `"A10G"`, `"L40S"`. A family such as `H100` matches any of its variants; `"H100!"` pins the exact variant. A list such as `["H100", "H200"]` accepts either.

## What Nodus adds

Nodus places every call on the cheapest capacity that finishes it on time, and it stops work before money runs out. These arguments have no Modal equivalent:

```python
@app.function(
    gpu="H100",
    max_cost=40,             # a hard cap in USD across this Function's workers
    checkpoint="/nodus/state",  # files here are saved and restored if capacity is reclaimed
    interruptible=True,      # allow cheaper interruptible capacity; progress is kept through the checkpoint
    region=["us", "eu"],     # region classes, not provider regions
)
def train(lr: float) -> dict: ...

print(train.estimate(3e-4))  # dry-run: expected cost, cold and warm start, the hold it needs
```

* `f.estimate(...)` returns the expected cost and start time of one call before you run it.
* A cold start on a GPU with no warm worker prints its expected wait, so a long first call is not a surprise.
* Errors are typed: `nodus.errors.InsufficientCredits` states the amount needed and how to add credit.
* `nodus.Job`, `nodus.Workspace` and `nodus.llm` cover batch jobs, development machines and inference with the same credentials.

## Differences to know

* **Parametrized classes** (`modal.parameter()`) are not supported. Configure the class in its `@nodus.enter()` hook instead. Calling `MyClass(arg=...)` raises `nodus.errors.Unsupported`.
* **The Python minor version** of the image must equal yours, as in Modal. `Image.debian_slim()` defaults to your version; a mismatch is refused before anything runs.
* **Web endpoints** (`@modal.web_endpoint`, `@modal.asgi_app`) are not available. Use `nodus.InferenceEndpoint` for model serving.
* **`modal.Dict` and `modal.Queue`** have no equivalent. Pass data through return values, a Volume or your own database.
* **Clustered Functions** (Beta) run each `.remote()` or `.spawn()` as one gang; `.map()` over a clustered Function raises `nodus.errors.Unsupported`.
* **Timeouts and durations** accept Go-style strings (`"90s"`, `"6h"`) as well as seconds. Days are not a unit.
* **Money** is always a decimal amount in USD (`max_cost=40` or `"40.00"`).

## Next steps

* [Python SDK guide](https://nodus-platform-site.pages.dev/docs/guides/python/)
* [Functions and classes](https://nodus-platform-site.pages.dev/docs/guides/python/functions/)
* [Sandboxes](https://nodus-platform-site.pages.dev/docs/guides/python/sandboxes/)
