The official Python client for the mnml API: architecture renders, edits, enhancements and video, from your own product.
- Every endpoint of API v2: renders, edits, enhancements, video, jobs, account, engines
- Images sent in the call itself: a link, bytes, a
Pathor an open file, no upload step - Retries, timeouts and idempotency keys handled for you
create_and_wait()andjobs.wait()to poll a job until it settles,files.download()to keep its output- Webhook signature verification, with typed events
- Typed keyword arguments and answers (
TypedDict), checked against the API's OpenAPI document - No dependencies. Python 3.9+
pip install mnml-aiCreate an API key in the console, then:
from pathlib import Path
from mnml_ai import Mnml
mnml = Mnml() # reads MNML_API_KEY
job = mnml.renders.create_and_wait(
mode="exterior",
image=Path("massing.png"), # or a link: "https://example.com/massing.png"
prompt="Timber facade, late afternoon light, olive trees",
)[0]
print(job["status"], job["outputs"][0]["url"] if job["outputs"] else None)Every call is paid from your prepaid API balance, in US dollars. Top it up on Billing.
Every amount the API answers with has a US-dollar key, a number to four decimals (0.2475 is
24.75 cents):
| Where | Dollar keys |
|---|---|
a create call (renders.create …) |
charged_usd |
jobs.get() |
charged_usd, refunded_usd |
jobs.cancel() |
refunded_usd |
account.get() |
balance_usd, key.daily_limit_usd, limits.daily_usd |
engines.list() |
prices.render_usd, prices.edit_usd, video prices[].usd |
the credits.low webhook |
balance_usd, threshold_usd |
account = mnml.account.get()
print(f"${account['balance_usd']} left")The credit keys beside them (credits_charged, credits_refunded, credits.spendable,
daily_credit_limit, daily_credits, prices.render and edit, video credits, and the
webhook's balance and threshold) are deprecated: each is documented in mnml_ai.types with
the dollar key to read instead, and a later release removes them.
mnml = Mnml(
api_key=None, # defaults to the MNML_API_KEY environment variable
max_retries=2, # retries for a 429, a 5xx or a dropped connection
timeout=60.0, # seconds, per request
)| Method | Endpoint | What it does |
|---|---|---|
renders.create(...) |
POST /v2/renders |
Render from an image, or from a prompt alone |
edits.create(...) |
POST /v2/edits |
Edit or erase, over the whole image or a region |
enhancements.create(...) |
POST /v2/enhancements |
Upscale, enhance, remove the background, outpaint |
videos.create(...) |
POST /v2/videos |
Video from a still |
jobs.get(id) |
GET /v2/jobs/{id} |
Read a job |
jobs.cancel(id) |
POST /v2/jobs/{id}/cancel |
Cancel a job (refunded if it had not produced yet) |
account.get() |
GET /v2/account |
Balance, tier and limits for this key |
engines.list() |
GET /v2/engines |
Engines, video models, prices and capabilities |
files.download(output, to=None) |
GET /v2/files/{id} |
A job output's bytes, from its signed link |
jobs.wait(id, ...) |
GET /v2/jobs/{id} |
Read a job until it succeeds, fails or is cancelled |
jobs.wait_all(ids, ...) |
GET /v2/jobs/{id} |
wait for several jobs at once |
jobs.stream(id) |
GET /v2/jobs/{id}/events |
Follow a job as it changes, over one connection |
<resource>.create_and_wait(...) |
the create, then the job | Start a job and wait for it (a render: every job) |
Create calls take the API's fields as keyword arguments, exactly as the
reference names them, typed so your editor completes
them, and every call returns the answer's data as a dict. A field the API does not take is a
TypeError before anything is sent. create_and_wait is on renders, edits, enhancements and
videos; a render returns a list with one job per count, the others one job.
engine, mode, aspect_ratio, camera_movement, motion and the video model are typed with
the values the API takes. engines.list() is the live list, with each engine's price and what it
can do:
for engine in mnml.engines.list()["engines"]:
print(engine["id"], f"${engine['prices']['render_usd']}", engine["capabilities"]["max_references"])Send the image in the call, as image:
from pathlib import Path
mnml.renders.create(image="https://example.com/plan.png", prompt="Brick and glass, dusk")
mnml.renders.create(image=Path("plan.png"), prompt="Brick and glass, dusk")
mnml.renders.create(image=png_bytes, prompt="Brick and glass, dusk")A str is sent as it is: a public https:// link, a data:image/...;base64, URI or bare base64.
Bytes, a Path or an open binary file is read and sent as a data URI, up to 15 MB per image. A
str is never read as a file, so pass Path("plan.png"), not "plan.png". image_url is the
old name of image and still works.
Every place an image goes takes the same: image, each of references, an edit's mask and a
video's end_frame. A reference can also be a dict with a mode:
mnml.renders.create(
image=Path("massing.png"),
references=[Path("timber.jpg"), {"image": "https://example.com/brick.png", "mode": "material"}],
prompt="Timber and brick, overcast",
)To work on a finished job's output without downloading it, pass its job_id:
mnml.edits.create(job_id=job["id"], prompt="Dark brick instead of render")# Change one thing, inside a box (fractions of the image from its top-left)
mnml.edits.create(
job_id=job_id, prompt="A red front door", region={"box": {"x": 0.4, "y": 0.5, "width": 0.2, "height": 0.4}}
)
# Or paint a mask: a PNG, white to change and black to keep
mnml.edits.create(image=Path("house.jpg"), mask=Path("door-mask.png"), prompt="A red front door")
# Remove what a region covers
mnml.edits.create(job_id=job_id, kind="erase", region={"box": {"x": 0.1, "y": 0.6, "width": 0.2, "height": 0.3}})
# Upscale, enhance, cut out, or extend to a new frame
mnml.enhancements.create(job_id=job_id, kind="outpaint", aspect_ratio="16:9")
# A ten-second camera move
mnml.videos.create(job_id=job_id, model="v2.0-flash", duration_seconds=10, camera_movement="orbit-right")A render can come back finished from the create call itself: pass wait (1-90 seconds) and the
answer's jobs carry the outputs. A read takes wait too, and answers as soon as the job settles.
create_and_wait and jobs.wait use both, so a render usually takes one or two calls, not a
polling loop.
started = mnml.renders.create(image=Path("massing.png"), prompt="Brick, dusk", wait=90)
print(started.get("jobs", [{}])[0].get("outputs")) # set when it finished in time
read = mnml.jobs.get(started["id"], wait=90) # held until it settles
job = mnml.jobs.wait(job_id, interval=3.0, timeout=600.0)jobs.wait raises MnmlTimeoutError when its own time runs out. The job keeps running, so
read it again later. videos.create_and_wait reads every 10 seconds for up to 20 minutes unless
you say otherwise. For long jobs such as video, a webhook beats polling.
jobs.stream follows one job over a single connection (Server-Sent Events) for up to ten
minutes: each change of the job, then one last event, after which the loop ends. It suits a long
job in a script with no server to take a webhook.
for event in mnml.jobs.stream(job_id):
if event["type"] == "job":
print(event["job"]["status"]) # as it changes
elif event["type"] == "done":
print(event["job"]["outputs"][0]["url"]) # settled
elif event["type"] == "timeout":
break # still running after ten minutes: stream it again
else:
print(event["error"]["message"]) # the API could not read it; the job still runsLeaving the loop hangs up; the job keeps running. A connection that drops before the last event
raises MnmlError with STREAM_ENDED.
Output links are signed and expire after an hour or two. Download what you want to keep:
file = mnml.files.download(job["outputs"][0], to="render.jpg")
print(file["content_type"], len(file["data"]))The link's signature is its credential, so your key is not sent with it. An expired link raises
MnmlError with NOT_FOUND; read the job again for fresh links.
A refusal raises MnmlError, carrying the API's code, the HTTP status and the
request_id to quote to support:
from mnml_ai import MnmlError
try:
mnml.renders.create(image=url, prompt=prompt)
except MnmlError as err:
if err.code == "INSUFFICIENT_CREDITS":
... # top up, then send it again
raiseOn VALIDATION_FAILED, err.issues lists each refused field. Every code is described at
developers.mnml.ai/docs/errors.
The client retries a 429, a 5xx or a dropped connection, twice by default. It waits out
Retry-After when that is a minute or less, and otherwise raises the error to you. That
covers a daily limit, which resets at midnight UTC.
Every call that can spend credits carries an Idempotency-Key, and the client reuses the same
key across its own retries, so a retry never charges twice. To make your own retries safe as
well, pass a key you choose:
mnml.renders.create(prompt=prompt, image=url, idempotency_key=f"order-{order_id}")Add an endpoint and copy its signing secret on the Webhooks page. Then verify each delivery with the raw request body, before parsing it:
import os
from flask import Flask, request
from mnml_ai import WebhookVerificationError, verify_webhook
app = Flask(__name__)
@app.post("/webhooks/mnml")
def mnml_webhook():
try:
event = verify_webhook(request.get_data(), request.headers, os.environ["MNML_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "", 400
if event["type"] == "job.succeeded":
job = event["data"] # the job, as jobs.get returns it
elif event["type"] == "credits.low":
balance = event["data"]["balance_usd"] # US dollars; and event["data"]["threshold_usd"]
return "", 204verify_webhook raises WebhookVerificationError on a bad signature or a stale timestamp
(over five minutes). Events: job.succeeded, job.failed, job.canceled, credits.low and
webhook.test; checking event["type"] narrows event["data"] for a type checker. The scheme is
Standard Webhooks. See
examples/ for FastAPI and Django.
The answers are typed as TypedDicts in mnml_ai.types (Job, RenderStarted, JobStreamEvent,
Account, Engines, WebhookEvent and more), the values the API takes as Literals (Mode,
EngineId, CameraMove …), and the package ships py.typed. A test in this repository checks
every keyword argument, field and listed value against the API's OpenAPI document
(spec/openapi.json).
The mnmlai.dev routes (/v1/archDiffusion-v46, /v1/upscale, /v1/status/{id} …) still answer
at /v1 on api.mnml.ai and on api.mnmlai.dev, deprecated, so nothing breaks while you move.
The SDK speaks API v2 only. Each old route has a v2 call that does the same job:
| mnmlai.dev route | SDK call |
|---|---|
archDiffusion-v46 |
renders.create(engine="v4.6-ultra", ...) |
archDiffusion-v45, -v45-lite |
renders.create(engine="v4.5-ultra"), "v4.5-fast" |
archDiffusion-v44, -v44-lite |
renders.create(engine="v4.4-ultra"), "v4.4-fast" |
archDiffusion-v43, -v43-lite, -v42, -v42-lite, -v41 |
renders.create(engine="v4.3"), "v4.3-fast" |
mixture-of-experts |
renders.create(mode=..., ...) (expert_name is the mode) |
exterior, interior, sketch-to-img |
renders.create(engine="v3.1", mode=..., ...) |
style/transfer |
renders.create(references=[{..., "mode": "style"}], ...) |
imagine-ai |
renders.create(mode="text-to-render", prompt=...) |
virtual-staging-ai (v1 and v2) |
renders.create(mode="interior", ...) |
inpaint |
edits.create(prompt=..., mask=...) or region |
ai-eraser |
edits.create(kind="erase", region=...) |
upscale, render/enhancer |
enhancements.create(kind="upscale"), kind="enhance" |
video-v20-flash, video-v20-cinematic, video-ai |
videos.create(model="v2.0-flash"), "v2.0", "v1.1" |
status/{id} (v1 and v2) |
jobs.get(id) or jobs.wait(id) |
credits |
account.get() (balance_usd) |
v2 takes the image in the request, as mnmlai.dev did: pass it as image (a link, bytes or a
Path), or a job_id for a finished job's output. The full guide is at
developers.mnml.ai/docs/migrate.