Zeblok Python SDK — User Guide
Control the Ai-MicroCloud platform from Python. This guide explains every operation: what it does, which API it calls, the code to copy, and what you get back.
New here? Start with Install → Get your keys → Connect → Your first script, then jump to the component you need. Authentication explained covers every credential in one place.
This guide follows the Web UI menu.
The sections below are named and grouped exactly like the sidebar you already use — Workspace for things that are running, Platform → Imports for the templates they start from, Configurations and IAM for setup. If you can find it in the app, you can find it here.
Two menu entries are not in this guide.
Gen AI Workspace → Knowledge Distillations and Domain Adaptations have no SDK support yet — there is no client.… for them, so use the Web UI for those. Home, Support and About are pages, not operations. Everything else in the sidebar is covered below.
Table of Contents
- Getting started
- Workspace (things that are running)
- Platform › Imports (the templates they start from)
- Configurations
- IAM (people and access)
- Platform utilities
- Reference
What this guide is
The Zeblok Python SDK lets you control the Ai-MicroCloud platform from Python code, instead of clicking in the web interface. Anything you can do in the Web UI — create plans, start workstations, deploy microservices, import AI models — you can do from a script.
Every section below follows the same pattern, so once you learn one, you know them all:
| Part | What it tells you |
|---|---|
| What this does | One simple sentence about the operation. |
| API | The exact platform endpoint being called, in case you need to debug. |
| Code | A ready-to-copy example. |
| You get back | What the call returns, so you know what to expect. |
Two kinds of operations. Reads (list, get, check) only look at data — they are safe to run any time. Writes (create, update, delete, start, stop) change real things on the platform. Read first, write carefully.
Step 1 — Install the SDK
You need Python 3.9 or newer.
pip install "zeblok-sdk[all]"
The [all] part adds optional cloud-storage support (AWS and Azure). If you only use MinIO storage, plain pip install zeblok-sdk is enough.
Step 2 — Get your keys
Three different credentials, for three different jobs. You probably need only the first one.
Open the Web UI and go to Platform → API Keys & Secrets. There are three tabs:
| Tab | What it gives you | Used for | Need it? |
|---|---|---|---|
| Microcloud | API Key + API Secret | Almost everything — this is your main login | Always |
| Gen AI | A long token (its short name starts with zbl_) |
Chatting with AI models and agents | Only for chat |
| Object Store | User + Secret Key + endpoint + bucket | Uploading code folders for pipelines and AI-APIs | Only for pipelines / AI-APIs |
How to get the main pair (Microcloud)
- Web UI → Platform → API Keys & Secrets → Microcloud tab.
- Click Generate (or Regenerate Keys if you already have a pair).
- Copy both values — the API Key and the API Secret.
The secret is shown only once. The API Secret and the Gen-AI token appear at the moment they are created and never again. Copy them somewhere safe straight away. If you lose one, generate a new pair — but see the warning below first.
Regenerating cancels the old pair immediately. Every script, notebook and colleague still using the old key stops working the second you click Regenerate. Only do it when you mean to.
Where to keep them
Do not paste keys into code you share. Put them in environment variables and read them in Python:
# in your terminal, once per machine
export ZBL_APP_URL="https://app.your-env.zeblok.com"
export ZBL_ACCESS_KEY="777d1243…"
export ZBL_ACCESS_SECRET="c61eb85a…"
# in your script
import os
from zeblok import ZeblokClient
client = ZeblokClient(
os.environ["ZBL_APP_URL"],
os.environ["ZBL_ACCESS_KEY"],
os.environ["ZBL_ACCESS_SECRET"],
)
In a Jupyter notebook you can use os.environ.get("ZBL_ACCESS_KEY", "<paste here>") so the notebook works either way — from the environment when it is set, from the pasted value when it is not.
Step 3 — Connect
One client object. Everything else hangs off it.
Create your client
What this does: connects the SDK to your platform. You do this once at the top of your script, then use client everywhere.
API GET /health (used to find the right address — no login needed)
from zeblok import ZeblokClient
client = ZeblokClient(
"https://app.your-env.zeblok.com", # the same address you use in your browser
"<your-api-key>",
"<your-api-secret>",
)
print(client.health()) # {'status': 'ok', ...} means the platform is reachable
print(client.config()) # {'tier': 'pro', ...} means your key works too
You get back: a client object. Everything in this guide hangs off it — client.plans, client.agents, client.users, and so on. Creating it makes no API call for those; each part is built the first time you touch it.
Paste whatever address you use in the browser.
The web app and the API live on different hosts (app.… vs backend.…, or frontend-service.… vs backend-service.…), and which one is right differs per environment. The SDK checks and switches for you. If you already know the exact API host and want no extra check, pass resolve_url=False.
A trailing / is fine — the SDK removes it. So is leaving off https://.
Check that you are really connected
What this does: three small calls that tell you exactly how far you got. Run them before anything else when something is not working.
API GET /health · GET /api/v1/config · GET /api/v1/users/credentials/status
print(client.health()) # is the platform up? (no login)
print(client.config()) # do my key and secret work? (login)
print(client.keys.credentials_status()) # which key pair am I using?
You get back:
| Call | Good answer | If it fails |
|---|---|---|
health() |
{'status': 'ok', …} |
Wrong address, VPN off, or the platform is down. Nothing else can work. |
config() |
{'tier': 'pro', …} |
Address is fine but your key or secret is wrong or was replaced. |
keys.credentials_status() |
{'hasCredentials': True, …} plus a preview of your key |
Compare the preview with the key you pasted — a mismatch means you are using an old pair. |
Authentication explained
Who you are, what you are allowed to do, and which credential each part of the SDK wants.
The platform uses three separate credentials. They are not interchangeable, and using the wrong one is the single most common reason a call fails. This section explains each one in plain terms.
The three credentials at a glance
| Credential | Looks like | What it proves | Where the SDK uses it |
|---|---|---|---|
| Platform key + secret (Microcloud tab) | two long hex strings | "I am this user on this platform" | Everything on client.… — plans, workstations, models, users… |
| Gen-AI key (Gen AI tab) | one very long token | "I may send prompts to models" | InferenceChat(...) and agent.chat(..., ai_key=…) |
| Object-store key + secret (Object Store tab) | a short username + a secret | "I may write to the file storage" | DataLake(...), used by pipelines and AI-APIs |
A fourth one exists but is rarely needed: a browser session token (JWT), used only for live push updates — see Live status updates.
1 · The platform key pair — your main login
How it works
What this does: you hand the key and secret to ZeblokClient once. From then on the SDK attaches them to every request for you. There is no login step, no session, and nothing expires on a timer — the pair works until someone regenerates it.
client = ZeblokClient(APP_URL, ACCESS_KEY, ACCESS_SECRET)
# every call below is authenticated automatically
client.plans.get_all()
client.users.list()
You get back: nothing to manage. If you need the raw credentials object for an advanced case, it is client.auth.
Check which key you are using
What this does: asks the platform about the pair you are currently sending. Useful when you suspect you pasted an old key.
API GET /api/v1/users/credentials/status
print(client.keys.credentials_status())
# {'hasCredentials': True, 'keyPreview': '777d…0414', 'createdAt': '…'}
You get back: whether a pair exists, and a short preview of it. Compare the preview with what you pasted.
Rotate (replace) your platform key
What this does: creates a brand-new key and secret, and cancels the old pair immediately.
API POST /api/v1/users/generatekeys
new_pair = client.keys.generate()
print(new_pair["apiKey"]) # save both of these NOW
print(new_pair["apiSecret"]) # the secret is never shown again
You get back: the new key and secret.
This locks you out mid-script.
The client you are holding still has the old pair, which is now dead — the very next call fails with 401 User not authenticated. After rotating, update your environment variables and build a new client:
client = ZeblokClient(APP_URL, new_pair["apiKey"], new_pair["apiSecret"])
Anyone else using the old pair — colleagues, notebooks, scheduled jobs — is also cut off. Tell them first.
2 · The Gen-AI key — for talking to models and agents
Chat does not use your platform key pair. It uses a separate bearer token created in the Gen AI tab, or from the SDK. This is the key you pass as ai_key.
Create a Gen-AI key from Python
What this does: mints a token you can use to chat with any model or agent you have access to.
API POST /api/v1/users/ai-keys
key = client.ai_keys.create(
name="my-chat-key", # any label you will recognise later
key_type="user", # "user" for you; "user-agent" for an agent account
expiry_days=30, # how long it stays valid
)
AI_KEY = key["aiKey"] # the token you actually use — SHOWN ONCE
KEY_ID = key["credential"]["_id"] # the id you need to revoke it later
print("save this now:", AI_KEY)
You get back: a dictionary with two parts that matter — aiKey (the long token, shown once) and credential._id (the id used to revoke it). The short zbl_… value you see in the Web UI is only a preview, not the token.
Use it, list them, revoke one
What this does: everything else you do with Gen-AI keys.
API GET /api/v1/users/ai-keys · /ai-keys/usage · DELETE /ai-keys/:id
# use it — chat with a running model
from zeblok.llm import InferenceChat
chat = InferenceChat(client.auth, spawned_inference_id=INF_ID, ai_key=AI_KEY)
print(chat.chat(prompt="Hello!"))
# use it — chat with a running agent
print(client.spawned_agent(AGENT_ID).chat("Hello!", ai_key=AI_KEY))
# see all your keys and how many tokens they have spent
print(client.ai_keys.list())
print(client.ai_keys.usage())
# cancel one
client.ai_keys.revoke(KEY_ID) # use credential["_id"], not the zbl_… preview
You get back: your key list, usage counts, and a confirmation when revoking.
Revoking is instant. Anything still using that token — including the notebook you are sitting in — stops working right away.
3 · The object-store credentials — for pipelines and AI-APIs
Pipelines and AI-APIs work by zipping your code folder and uploading it to the platform's file storage. That upload needs storage credentials, which are separate again.
Let the platform tell you its own values
What this does: fetches your object-store username, secret, bucket and endpoint, so you do not have to copy them out of the Web UI by hand.
API GET /api/v1/users/…object-store credentials
creds = client.users.object_store_credentials()
print(creds)
# {'access_key': 'pyl', 'secret_key': '98d5…', 'bucket': '60638d4a533d-pyl',
# 'endpoint': 'https://minio-hl.your-env.zeblok.com:443'}
You get back: the four values you need for a DataLake.
The username is short (like pyl) — it is not your platform API key. Passing the API key here is the usual cause of InvalidAccessKeyId.
Build the storage handle
What this does: creates the DataLake object, checks the bucket and credentials really work, then attaches it to a client. Only pipelines and AI-APIs need this.
from zeblok.datalake import DataLake
creds = client.users.object_store_credentials()
dl = DataLake(
api_auth=client.auth,
access_key=creds["access_key"],
secret_key=creds["secret_key"],
bucket_name=creds["bucket"],
blob_url=creds["endpoint"],
)
# a client that can also run pipelines / AI-APIs
pl_client = ZeblokClient(APP_URL, ACCESS_KEY, ACCESS_SECRET, datalake=dl)
print("storage ready")
You get back: a client with pipelines and ai_apis enabled. If the credentials are wrong you get a clear message rather than a failure later during upload.
Datasets do not need this. client.datasets.upload_dataset(...) uploads through the platform itself, so it works with only your normal key pair.
What is my key allowed to do?
Check your own permissions
What this does: answers "am I allowed to do X?" without you having to try it and read the error.
API GET /api/v1/auth/me/permissions
me = client.iam.my_permissions()
print(me.allows("plans", "list")) # True / False
print(me.allows("buckets", "create")) # True / False
print(me.raw) # the full permission record (a property — no brackets)
print("my role:", me.raw.get("role")) # e.g. 'admin' or 'superadmin'
You get back: a permission set. allows(resource, action) is the quick yes/no; .raw is everything.
Some administration calls need the superadmin role, not just admin. See IAM › Roles for the exact list.
When authentication goes wrong
| What you see | What it actually means | Fix |
|---|---|---|
401 · User not authenticated |
The key or secret is wrong, mistyped, or was replaced by a newer pair. | Re-copy both values from the Microcloud tab. Check client.keys.credentials_status(). |
403 · User not authorized / Insufficient role privileges |
Your credentials are fine — your role simply may not do this. | Ask an administrator, or use a key with the required role. |
| HTML or a parse error instead of data | You are pointed at the web app host, not the API host. | Let the SDK resolve it — do not pass resolve_url=False. |
401 when deleting a running agent |
Not an auth problem at all. The platform refuses to delete something that is running and reports it with the wrong status code. | sa.delete(stop_first=True) |
InvalidAccessKeyId on upload |
You used the platform API key as the object-store username. | Use client.users.object_store_credentials(). |
| Chat says the model is offline, but it is running | You sent the platform key instead of the Gen-AI key, or the wrong model name. | Pass ai_key=, and leave model_name out so the SDK detects it. |
Your first script
Copy this, fill in three values, run it. It only reads — it changes nothing.
This is the shortest path from "installed" to "it works". It connects, proves your key is good, and shows you the ids you will need for everything else in this guide.
import os
from zeblok import ZeblokClient
APP_URL = os.environ.get("ZBL_APP_URL", "https://app.your-env.zeblok.com")
ACCESS_KEY = os.environ.get("ZBL_ACCESS_KEY", "<your-api-key>")
ACCESS_SECRET = os.environ.get("ZBL_ACCESS_SECRET", "<your-api-secret>")
client = ZeblokClient(APP_URL, ACCESS_KEY, ACCESS_SECRET)
# 1 — is the platform reachable, and does my key work?
print("health:", client.health())
print("config:", client.config())
# 2 — the three ids almost every other call needs
DC_ID = client.datacenters.get_all(print_stdout=False)[0]["id"]
PLAN_ID = client.plans.get_all(print_stdout=False)[0]["id"]
NAMESPACE_ID = client.namespaces.get_all(print_stdout=False)[0]["id"]
print("datacenter:", DC_ID)
print("plan :", PLAN_ID)
print("namespace :", NAMESPACE_ID)
# 3 — what is already running
print("workstations running:", len(client.workstations.spawned() or []))
print("microservices running:", len(client.microservices.spawned() or []))
# 4 — what am I allowed to do?
me = client.iam.my_permissions()
print("role:", me.raw.get("role"), "| can list plans:", me.allows("plans", "list"))
Then what?
| You want to… | Go to |
|---|---|
| Start a JupyterLab for yourself | Workstations |
| Deploy an app or API | Microservices |
| Serve an AI model and chat with it | Inference |
| Build an AI assistant with tools | Agents |
| Upload files | Datasets |
| Add colleagues and control access | Users & access |
| Stop, resize or delete something already running | Managing what you started |
Two kinds of call.
Reads (get_all, get_by_id, list, validate_id) only look — run them freely. Writes (create, update, delete, spawn, stop) change real things and can cost real resources. A useful habit while learning: put your writes behind a flag.
RUN_WRITES = False
if RUN_WRITES:
client.plans.create(...)
Workspace › Workstations
Your own development machine in the cloud — usually JupyterLab.
Steps: 1. Pick image + plan → 2. Start → 3. Open it → 4. Stop / resize → 5. Delete
This page is about running workstations: starting one, opening it, and controlling it. The images you start them from live in Imports › Workstations.
| What you want to do | Code | API |
|---|---|---|
| Start one | client.workstations.spawn(...) |
POST /api/v1/spawned-images |
| List running ones | client.workstations.spawned() |
GET /api/v1/spawned-images |
| Pod name → id | client.workstations.get_spawned_id_by_name(pod) |
GET /api/v1/spawned-images |
| Check status | handle.status() |
GET /api/v1/spawned-images/:id |
| Get the open link | handle.open_url() |
(built from the status) |
| Stop / Start | handle.stop() · handle.start() |
`PUT /api/v1/spawned-images/:id/stop |
| Restart | handle.restart() |
PUT /api/v1/spawned-images/:id/restart |
| Change it while running | handle.edit(...) |
PUT /api/v1/spawned-images/edit/:id |
| Resize / move plan | handle.reconfigure(...) |
PUT /api/v1/spawned-images/reconfigure/:id |
| Share it | handle.share([...]) |
PUT /api/v1/spawned-images/share/:id |
| Delete | handle.delete(stop_first=True) |
DELETE /api/v1/spawned-images/:id |
Start a workstation
What this does: starts a real workstation from a catalog template. It costs resources.
API POST /api/v1/spawned-images
ws = client.workstations.get_by_id(WORKSTATION_ID, print_stdout=False)
pod_name, url = client.workstations.spawn(
display_name=ws["display_names"][0], # which image tag to use
workstation_id=WORKSTATION_ID, # the template
plan_id=PLAN_ID, # how big
workstation_name="my-workstation-1", # a unique name
namespace_id=NAMESPACE_ID, # where it runs
)
print("Started:", pod_name, url)
You get back: the pod name and the URL.
Important: spawn() returns the pod name, not the database id. To manage the workstation later you need the id — see the next box.
Find and manage a running workstation
What this does: lists what is running, gets a management handle, and opens it in your browser.
API GET /api/v1/spawned-images · GET /api/v1/spawned-images/:id
running = client.workstations.spawned()
for w in running:
print(w["_id"], "|", w.get("k8sName"), "|", w.get("status"))
WS_ID = running[0]["_id"] # the database id — use this, not the pod name
handle = client.spawned("image", WS_ID)
print("Status:", handle.status().get("status"))
print("Open it here:", handle.open_url()) # paste in a browser — JupyterLab opens, already logged in
You get back: a list of running workstations, and a handle you can control.
open_url() is the same link as the Open button in the Web UI. For workstations it also adds the login token, so the link opens JupyterLab without asking for a password.
Stop, start, restart, resize, delete
What this does: the everyday controls for a running workstation.
API PUT /api/v1/spawned-images/:id/stop|start|restart · DELETE /api/v1/spawned-images/:id
handle.stop() # stop it (saves money, keeps everything)
handle.start() # start it again
handle.restart() # restart in one step
handle.reconfigure(plan_id=OTHER_PLAN_ID) # move to a bigger/smaller plan
handle.edit(resource_details={"CPU": 2, "memory": 4, "GPU": 0}) # change resources in place
handle.delete(stop_first=True) # delete (stops it first if it is running)
You get back: each call returns the platform's response.
The platform refuses to delete a workstation that is still running. delete(stop_first=True) stops it, waits, then deletes — all in one call.
Everything after it is started — status, logs, the open link, stop, start, restart, resize, share and delete — works the same for every workload type and is described once in Managing what you started.
Start several at once
Spawn many workstations in one go
What this does: starts several workstations from the same image, giving each a name that is not already taken. One failure does not stop the rest.
API POST /api/v1/spawned-images (once per workstation)
from zeblok.batch import unique_names, batch
names = unique_names("my-ws", 3, existing=client.workstations.spawned)
result = batch(names, lambda n: client.workstations.spawn(
display_name=ws["display_names"][0], workstation_id=WORKSTATION_ID,
plan_id=PLAN_ID, workstation_name=n, namespace_id=NAMESPACE_ID))
print(result.summary()) # "3 succeeded, 0 failed"
You get back: a result object listing what worked and what did not. Each spawn returns (pod_name, url) — convert a pod name with get_spawned_id_by_name() before managing it.
More on names and error handling in Creating many at once.
These are real workstations and they cost resources. Start with a count of 1 or 2 while you are testing, and delete them afterwards.
Find and inspect a running workstation
List what is running, and get the id you manage with
What this does: shows every workstation currently running, and converts the pod name that spawn() gave you into the database id every other call needs.
API GET /api/v1/spawned-images
running = client.workstations.spawned()
print(f"{len(running)} workstations running")
for w in running[:5]:
print(" -", w["_id"], "|", w.get("k8sName"), "|", w.get("status"))
# from a spawn() you just did:
WS_ID = client.workstations.get_spawned_id_by_name(pod_name)
# or just take one from the list:
WS_ID = running[0]["_id"]
You get back: a list of running workstations. _id is the id you manage with; k8sName is the pod name; status is its current state.
Use _id, not k8sName. Management endpoints check that the id is a 24-character value, so a pod name is refused with 400 Validation failed — which reads like your request was malformed when it was only the wrong id.
Check status and resource usage
What this does: reads the workstation's current record and how much CPU, memory and GPU it has been using.
API GET /api/v1/spawned-images/:id · /:id/utilization
handle = client.spawned("image", WS_ID)
st = handle.status()
print("state:", st.get("status"), "| name:", st.get("name"))
print(handle.utilization("5m")) # last 5 minutes
print(handle.logs()) # what the container printed
You get back: the full record, usage figures, and the log text.
On a stopped workstation the usage figures come back empty and the logs may say "not found". That is expected — start it first.
replica_status() is not available for workstations — it exists only for microservices. The SDK says so rather than sending a request that would fail.
Get the link that opens JupyterLab
What this does: gives you the same URL as the Open button in the Web UI — and for a workstation it attaches the login token, so the link opens JupyterLab already signed in.
API (built from the status record — no extra call)
rec = handle.status()
print("open this in a browser:", handle.open_url(rec))
print("in-cluster address:", handle.internal_url(rec))
for e in handle.endpoints(rec):
print(f" {e.get('label')}: {e.get('url')} [{e.get('scope')}]")
You get back: the browser link, the address other workloads should use, and every endpoint it exposes.
Already-authenticated link. When the URL carries no token, the SDK appends the workstation's Jupyter token as /lab?token=…, exactly as the Web UI does. If the URL already has one, it is left alone.
open_url() returns None while it is still starting, when it is stopped, or when it is internal-only. That is an answer, not a failure.
Control a running workstation
Stop and start
What this does: stops the workstation — freeing its CPU, GPU and memory while keeping the workstation and everything on its disk — and starts it again.
API PUT /api/v1/spawned-images/:id/stop · /start
handle.stop()
handle.start()
handle.wait_until_ready(timeout=600) # block until it says 'running'
You get back: the platform's response to each call.
Do not stop the workstation you are working in. If you run this from a Zeblok JupyterLab and pick your own workstation, you kill your own session. Check the name in status() first.
Restart in place
What this does: recreates the workstation without deleting it. It keeps the same id, plan, settings and URL — unlike stop-then-start, which is two calls.
API PUT /api/v1/spawned-images/:id/restart
handle.restart()
print("state after restart:", handle.status().get("status"))
handle.wait_until_ready(timeout=600)
You get back: the platform's response, then the new state.
Use this after an edit() or reconfigure() so the new settings take effect.
Edit it while it runs
What this does: changes what is inside the running workstation — its resources, or the storage bucket mounted into it. This is the Web UI's Edit dialog.
API PUT /api/v1/spawned-images/edit/:id
handle.edit(resource_details={"CPU": 2, "memory": 4, "GPU": 0})
# attach or detach a storage bucket
handle.edit(attach_bucket="yes", s3_bucket_name="<bucket>", bucket_mount_path="/data")
handle.edit(attach_bucket="no")
You get back: the updated record.
Workstations accept fewer fields than microservices. updated_replicas, configuration and docker_image are microservice-only; passing one here is refused straight away with a clear message rather than by the platform.
Move it to another plan (reconfigure)
What this does: changes what the workstation runs on — a bigger or smaller plan, or different resources.
API PUT /api/v1/spawned-images/reconfigure/:id
handle.reconfigure(plan_id=BIGGER_PLAN_ID)
# or resize without changing plan
handle.reconfigure(resource_details={"CPU": 2, "memory": 4, "GPU": 0})
# environment variables and ports live under `parameters`
handle.reconfigure(parameters={"envs": [{"key": "JUPYTER_TOKEN", "value": "x"}]})
You get back: the updated record.
Reconfiguring to the same plan is a harmless round-trip — handy for checking the call works before you move anything for real.
Share it with a colleague
What this does: gives someone else access to this workstation. Emails, usernames or ids all work — the SDK looks up anything that is not already an id.
API PUT /api/v1/spawned-images/share/:id
# who is available?
print([u["email"] for u in (client.users.list() or [])])
handle.share(["colleague@yourcompany.com"])
You get back: the updated record, with the resolved ids in its allowedUsers.
If a name matches nobody, the error lists every email you can see, so you can spot the right spelling.
Delete it
What this does: removes the workstation permanently, along with anything stored on its local disk.
API DELETE /api/v1/spawned-images/:id
handle.delete(stop_first=True) # stops it, waits, then deletes
remaining = [w["_id"] for w in (client.workstations.spawned() or [])]
print("still there?", WS_ID in remaining) # False
You get back: confirmation, then False proving it is gone.
The platform refuses to delete a running workstation. stop_first=True does the stop, the wait and the delete in one call.
Anything saved only on the workstation's own disk is lost. Put work you want to keep in a dataset or a mounted bucket first.
Workspace › Microservices
An application you deploy and keep running — an API, a database, a tool.
Steps: 1. Pick template + plan → 2. Deploy → 3. Check + open → 4. Edit / scale → 5. Delete
This page is about running microservices. The images you deploy from live in Imports › Microservices.
| What you want to do | Code | API |
|---|---|---|
| Deploy one | client.microservices.spawn(...) |
POST /api/v1/spawned-services |
| List running ones | client.microservices.spawned() |
GET /api/v1/spawned-services |
| Pod name → id | client.microservices.get_spawned_id_by_name(pod) |
GET /api/v1/spawned-services |
| Check status | handle.status() |
GET /api/v1/spawned-services/:id |
| Check replicas | handle.replica_status() |
GET /api/v1/spawned-services/replica-status/:id |
| Stop / Start / Restart | handle.stop() · start() · restart() |
`PUT /api/v1/spawned-services/:id/stop |
| Change while running | handle.edit(...) |
PUT /api/v1/spawned-services/edit/:id |
| Resize / move plan | handle.reconfigure(...) |
PUT /api/v1/spawned-services/reconfigure/:id |
| Share it | handle.share([...]) |
PUT /api/v1/spawned-services/share/:id |
| Delete | handle.delete(stop_first=True) |
DELETE /api/v1/spawned-services/:id |
Start a microservice
What this does: deploys a microservice from the catalog. Ports and environment variables come from the template automatically — the same as the Web UI does.
API POST /api/v1/spawned-services
ms = client.microservices.get_by_id(MICROSERVICE_ID, print_stdout=False)
print("Template defaults:", ms.get("parameters")) # ports and env vars it ships with
pod_name, url = client.microservices.spawn(
display_name=ms["display_names"][0],
microservice_id=MICROSERVICE_ID,
plan_id=PLAN_ID,
microservice_name="my-service-1", # unique, lowercase
namespace_id=NAMESPACE_ID,
# ports and envs are taken from the template when you leave them out.
# To override:
# ports=[{"protocol": "HTTP", "portIdentifier": "http", "number": 8080}],
# envs=[{"key": "LOG_LEVEL", "value": "debug"}],
)
You get back: the pod name and URL.
Manage a running microservice
What this does: find it, check it, open it, and control it.
API GET /api/v1/spawned-services · /:id · /replica-status/:id
running = client.microservices.spawned()
SVC_ID = running[0]["_id"]
handle = client.spawned("service", SVC_ID)
print("Status:", handle.status().get("status"))
print("Replicas:", handle.replica_status())
print("Open at:", handle.open_url())
print("Internal address (for other workloads):", handle.internal_url())
handle.edit(updated_replicas=2) # run 2 copies
handle.restart() # apply changes
handle.delete(stop_first=True) # remove it
You get back: status, replica counts, URLs, and the result of each action.
Everything after it is started — status, logs, the open link, stop, start, restart, resize, share and delete — works the same for every workload type and is described once in Managing what you started.
Start several at once
Spawn many microservices in one go
What this does: deploys several copies of a template under different names, skipping names already in use.
API POST /api/v1/spawned-services (once per service)
from zeblok.batch import unique_names, batch
names = unique_names("my-svc", 2, existing=client.microservices.spawned)
result = batch(names, lambda n: client.microservices.spawn(
display_name=ms["display_names"][0], microservice_id=MICROSERVICE_ID,
plan_id=PLAN_ID, microservice_name=n, namespace_id=NAMESPACE_ID))
print(result.summary())
You get back: a result object listing successes and failures. Each spawn returns (pod_name, url).
These are real, billable workloads. Keep the count small while testing.
Find and inspect a running microservice
List what is running, and get the id you manage with
What this does: shows every running microservice and converts a pod name into the database id.
API GET /api/v1/spawned-services
running = client.microservices.spawned()
print(f"{len(running)} microservices running")
for s in running[:5]:
print(" -", s["_id"], "|", s.get("k8sName"), "|", s.get("status"))
SVC_ID = client.microservices.get_spawned_id_by_name(pod_name) # from a spawn()
SVC_ID = running[0]["_id"] # or from the list
You get back: the list of running services. Use _id for every management call.
spawn() returns the pod name, not the id. Management endpoints reject a pod name with 400 Validation failed.
Check status, replicas and logs
What this does: reads the service's record, how many copies are actually up, and what the container printed.
API GET /api/v1/spawned-services/:id · /replica-status/:id · pod logs
handle = client.spawned("service", SVC_ID)
st = handle.status()
print("state:", st.get("status"), "| name:", st.get("name"))
print(handle.replica_status()) # how many copies are running
print(handle.logs()) # container output
print(handle.utilization("5m")) # CPU / memory / GPU
You get back: the record, the replica counts, the logs, and usage figures.
replica_status() works only for microservices. It is the one call in this family that no other workload type has — the platform has no such route for workstations, models or add-ons.
Get its URLs
What this does: gives you the public link, and the in-cluster address that other workloads should use to call this service.
API (built from the status record — no extra call)
rec = handle.status()
print("open at:", handle.open_url(rec))
print("call it from inside the cluster at:", handle.internal_url(rec))
for e in handle.endpoints(rec):
print(f" {e.get('label')}: {e.get('url')} [{e.get('scope')}]")
You get back: the external link, the internal address, and every endpoint it exposes.
Use the internal address for service-to-service calls. It stays inside the cluster, which is faster and does not depend on the public route being up.
Control a running microservice
Stop and start
What this does: stops the service, freeing its resources but keeping its configuration, then starts it again.
API PUT /api/v1/spawned-services/:id/stop · /start
handle.stop()
handle.start()
handle.wait_until_ready(timeout=600)
You get back: the platform's response to each call.
Restart in place
What this does: recreates the pod in one call, keeping the same id, plan, configuration and URL.
API PUT /api/v1/spawned-services/:id/restart
handle.restart()
print("state after restart:", handle.status().get("status"))
You get back: the platform's response, then the new state.
Run this after an edit() so new environment variables or a new image are actually picked up.
Edit it while it runs
What this does: changes what is inside the running service — how many copies, its resources, its environment variables, or even the image it runs.
API PUT /api/v1/spawned-services/edit/:id
handle.edit(updated_replicas=2) # run 2 copies
handle.edit(resource_details={"CPU": 2, "memory": 4, "GPU": 0}) # resize
handle.edit(configuration={"envs": [{"key": "LOG_LEVEL", "value": "debug"}]})
handle.edit(docker_image="myorg/app:2.0") # roll a new version
handle.restart() # apply it
You get back: the updated record.
Microservices accept the most edit fields of any workload type — replicas, resources, configuration and image. Workstations take only resources and bucket mounts.
Move plan, resize or rename (reconfigure)
What this does: changes what the service runs on, rather than what is inside it.
API PUT /api/v1/spawned-services/reconfigure/:id
handle.reconfigure(plan_id=PLAN_ID) # same plan = safe round-trip
handle.reconfigure(name="renamed-service")
handle.reconfigure(resource_details={"CPU": 2, "memory": 4, "GPU": 0})
handle.reconfigure(updated_replicas=2) # microservice-only
handle.reconfigure(parameters={"envs": [{"key": "LOG_LEVEL", "value": "info"}]})
You get back: the updated record.
One naming quirk the SDK hides for you. The platform calls this field configuration for microservices but parameters for workstations. You always pass parameters= and the SDK sends whichever the platform wants.
Share it with a colleague
What this does: gives someone else access. Emails, usernames or ids all work.
API PUT /api/v1/spawned-services/share/:id
print([u["email"] for u in (client.users.list() or [])])
handle.share(["colleague@yourcompany.com"])
You get back: the updated record, with the resolved ids in allowedUsers.
Delete it
What this does: removes the running service permanently.
API DELETE /api/v1/spawned-services/:id
handle.delete(stop_first=True)
remaining = [s["_id"] for s in (client.microservices.spawned() or [])]
print("still there?", SVC_ID in remaining) # False
You get back: confirmation, then False proving it is gone.
A running service cannot be deleted. stop_first=True handles the stop-wait-delete sequence.
Workspace › Orchestration Add-on
A cluster tool you run, like a Ray cluster for distributed computing.
Steps: 1. Pick template + plan → 2. Deploy → 3. Check → 4. Resize workers → 5. Delete
This page is about running add-ons. The templates live in Imports › Orchestration Add-ons.
| What you want to do | Code | API |
|---|---|---|
| Start one | client.orchestrations.spawn(...) |
POST /api/v1/spawned-k8s-addons |
| List running ones | client.orchestrations.spawned() |
GET /api/v1/spawned-k8s-addons |
| Check status | handle.status() |
GET /api/v1/spawned-k8s-addons/:id |
| Stop / Start / Restart | handle.stop() · start() · restart() |
`PUT /api/v1/spawned-k8s-addons/:id/stop |
| Change worker count | handle.reconfigure(...) |
PUT /api/v1/spawned-k8s-addons/:id/reconfigure |
| Share it | handle.share([...]) |
PUT /api/v1/spawned-k8s-addons/:id/share |
| Delete | handle.delete(stop_first=True) |
DELETE /api/v1/spawned-k8s-addons/:id |
Start an add-on and change its size
What this does: deploys a cluster add-on with a number of workers, then changes that number later.
API POST /api/v1/spawned-k8s-addons · PUT /:id/reconfigure
client.orchestrations.spawn(
orchestration_id=ADDON_ID,
plan_id=PLAN_ID,
namespace_id=NAMESPACE_ID,
orchestration_name="my-ray-cluster",
min_workers=1,
max_workers=3,
)
running = client.orchestrations.spawned()
handle = client.spawned("addon", running[0]["_id"])
handle.reconfigure(min_workers=2, max_workers=5) # grow the cluster
You get back: the running add-on and the result of the change.
Add-ons are resized differently from workstations and microservices: they use min_workers, max_workers, head_plan_id and worker_plan_id — not plan_id.
Everything after it is started — status, logs, the open link, stop, start, restart, resize, share and delete — works the same for every workload type and is described once in Managing what you started.
Start several at once
Spawn many add-ons in one go
What this does: deploys several add-ons under different names.
API POST /api/v1/spawned-k8s-addons (once per add-on)
from zeblok.batch import unique_names, batch
names = unique_names("my-oa", 2, existing=client.orchestrations.spawned)
result = batch(names, lambda n: client.orchestrations.spawn(
orchestration_id=ADDON_ID, plan_id=PLAN_ID, namespace_id=NAMESPACE_ID,
orchestration_name=n, min_workers=1, max_workers=2))
print(result.summary())
You get back: a result object listing successes and failures.
A cluster add-on takes real capacity — a head node plus its workers. Check client.plans.capacity(DC_ID) before starting several.
Find and inspect a running add-on
List what is running, and get the id you manage with
What this does: shows the running add-ons and resolves a pod name to the database id.
API GET /api/v1/spawned-k8s-addons
running = client.orchestrations.spawned()
print(f"{len(running)} add-ons running")
for a in running[:5]:
print(" -", a["_id"], "|", a.get("k8sName"), "|", a.get("status"))
OA_ID = running[0]["_id"]
# from a spawn(), which returns (pod_name, url):
# OA_ID = client.orchestrations.get_spawned_id_by_name(pod_name)
You get back: the list of running add-ons. Use _id to manage them.
Check status
What this does: reads the add-on's current record — whether the head node and workers are up.
API GET /api/v1/spawned-k8s-addons/:id
handle = client.spawned("addon", OA_ID)
st = handle.status()
print("state:", st.get("status"), "| name:", st.get("name"))
You get back: the full record.
Add-ons have no replica_status() — worker counts are set through reconfigure() instead, below.
Control a running add-on
Stop, start and restart
What this does: the everyday controls. Stopping frees the whole cluster; restarting recreates it in place.
API PUT /api/v1/spawned-k8s-addons/:id/stop | start | restart
handle.stop()
handle.start()
handle.restart()
print("state:", handle.status().get("status"))
You get back: the platform's response to each.
A Ray cluster takes a while to come back — wait_until_ready(timeout=600) blocks until it reports running.
Change the worker range or the plans
What this does: resizes the cluster. This is the one place the field names differ from every other workload type, because an add-on has a head node and workers.
API PUT /api/v1/spawned-k8s-addons/:id/reconfigure
handle.reconfigure(min_workers=2, max_workers=5) # grow the cluster
handle.reconfigure(head_plan_id=PLAN_ID, worker_plan_id=PLAN_ID)
You get back: the updated record.
There is no edit() for add-ons — the platform has no such route. reconfigure() is how you change them.
Add-ons take no plan_id. They use head_plan_id and worker_plan_id instead, because the head node and the workers can be different sizes. Passing plan_id is refused with the allowed list.
Share it with a colleague
What this does: gives someone else access to the cluster.
API PUT /api/v1/spawned-k8s-addons/:id/share
print([u["email"] for u in (client.users.list() or [])])
handle.share(["colleague@yourcompany.com"])
You get back: the updated record.
The URL shape differs per type — add-ons put the id before share, microservices and workstations after it. The SDK handles that; you always call handle.share([...]).
Delete it
What this does: removes the add-on and its whole cluster permanently.
API DELETE /api/v1/spawned-k8s-addons/:id
handle.delete(stop_first=True)
remaining = [a["_id"] for a in (client.orchestrations.spawned() or [])]
print("still there?", OA_ID in remaining) # False
You get back: confirmation, then False proving it is gone.
Any job still running on the cluster dies with it. Make sure nothing is mid-run before deleting.
Workspace › Ai-API & Pipelines
Turn a folder of your own code into something running on the platform.
Steps: 1. Set up storage → 2. Build + deploy → 3. Check state
These two work the same way, and they are the only part of the SDK that needs the object-store credentials. The SDK zips your folder, uploads it, the platform builds a container image from it, and then deploys it.
| An AI-Pipeline is… | An AI-API is… | |
|---|---|---|
| What it is | a job that processes data | a model served behind an HTTP endpoint |
| You give it | a folder with a Dockerfile |
a model folder |
| In the Web UI | Ai-API → Pipelines | Ai-API → APIs |
Step 1 — a client that can reach storage
Set up the DataLake
What this does: gives the SDK the storage credentials it needs to upload your folder. Without this, client.pipelines and client.ai_apis are not available.
from zeblok import ZeblokClient
from zeblok.datalake import DataLake
client = ZeblokClient(APP_URL, ACCESS_KEY, ACCESS_SECRET)
# ask the platform for its own storage values — no copying from the Web UI
creds = client.users.object_store_credentials()
dl = DataLake(
api_auth=client.auth,
access_key=creds["access_key"], # the SHORT username, not your API key
secret_key=creds["secret_key"],
bucket_name=creds["bucket"],
blob_url=creds["endpoint"],
)
pl_client = ZeblokClient(APP_URL, ACCESS_KEY, ACCESS_SECRET, datalake=dl)
print("storage ready")
You get back: a client with pipelines and ai_apis enabled. If the credentials are wrong you find out here, with a clear message, instead of halfway through an upload.
You can also paste the four values from API Keys & Secrets → Object Store. The username is short (like pyl); using the platform API key here causes InvalidAccessKeyId.
If the credentials are rejected even though they look right
, the storage account may never have been created for you. That is a platform-side issue — ask an administrator to re-provision your object-store user. It does not affect Datasets, which upload a different way.
Step 2 — build and deploy
Create and run an AI-Pipeline
What this does: zips your folder, uploads it, builds the image, and deploys it — in one call.
API upload → POST build → spawn
pl_client.pipelines.create_and_spawn(
ai_pipeline_name="my-pipeline",
ai_pipeline_folder_path="/home/me/my-pipeline", # must contain a Dockerfile
caas_plan_id=PLAN_ID, # the plan used to BUILD the image
ai_pipeline_plan_id=PLAN_ID, # the plan used to RUN it
namespace_id=NAMESPACE_ID,
)
# see what exists
print(pl_client.pipelines.get_all(state="ready", print_stdout=False)) # built, runnable
print(pl_client.pipelines.get_all(state="created", print_stdout=False)) # registered only
You get back: the image name it created. Watch the build under Container builds or in the Web UI.
Both plans must be in the same datacenter. The build plan and the run plan can be different sizes, but not different places — the SDK stops you before uploading if they are.
Prefer two steps? create(...) builds without deploying, then spawn(ai_pipeline_plan_id, namespace_id, ai_pipeline_image_name) deploys the built image later.
Create and serve an AI-API
What this does: the same flow, for a model you want to expose as an HTTP endpoint.
pl_client.ai_apis.create_and_spawn(
ai_api_name="my-api",
model_folder_path="/home/me/my-model",
ai_api_plan_id=PLAN_ID, # plan to RUN it on
caas_plan_id=PLAN_ID, # plan to BUILD it on
namespace_id=NAMESPACE_ID,
ai_api_type="llm",
)
deployed = pl_client.ai_apis.get_all(state="deployed", print_stdout=False)
print(deployed)
You get back: the image name, then the list of deployed APIs.
Get the key callers need
What this does: returns the access secret for a deployed AI-API — the credential whoever calls your endpoint has to send.
API GET /api/v1/k8s-deployments/:id/access-secret
deployed = pl_client.ai_apis.get_all(state="deployed", print_stdout=False)
dep_id = deployed[0]["id"]
print(pl_client.ai_apis.access_secret(dep_id))
You get back: the access secret for that deployment.
Empty lists are normal. On a fresh environment nothing has been built yet, so both lists come back empty. That is not an error.
Workspace › Inference
An inference is a model running as a live endpoint you can chat with.
Steps: 1. Pick model + GPU plan → 2. Serve it → 3. Wait until ready → 4. Chat → 5. Stop / delete
Serving a model has three stages: spawn it from the model catalog, manage the running instance, and chat with it. All three are below.
| What you want to do | Code | API |
|---|---|---|
| See the model catalog | client.inferences.get_all() |
GET /api/v1/inferences |
| See running models | client.inferences.get_all_spawned_inferences() |
GET /api/v1/spawned-inferences |
| Serve a model | client.inferences.spawn(...) |
POST /api/v1/spawned-inferences |
| Check status | handle.status() |
GET /api/v1/spawned-inferences/:id |
| See resource usage | handle.utilization("5m") |
GET /api/v1/spawned-inferences/:id/utilization |
| Stop / Start / Restart | handle.stop() · start() · restart() |
`PUT /api/v1/spawned-inferences/:id/stop |
| Resize | handle.reconfigure(...) |
PUT /api/v1/spawned-inferences/:id/reconfigure |
| Share with others | handle.share([...]) |
PUT /api/v1/spawned-inferences/:id/share |
| Delete | handle.delete(stop_first=True) |
DELETE /api/v1/spawned-inferences/:id |
| Which model names it serves | chat.served_models() |
GET <model-host>/v1/models |
| Chat with it | chat.chat(prompt=...) |
POST /api/v1/spawned-inferences/:id/chat/sdk |
Before you serve: pick a model and a plan
Browse the model catalog
What this does: lists the models the platform can serve, and opens one up. A model's modelTags carry the image and launch settings that serving it needs.
API GET /api/v1/inferences · /:id · /public
models = client.inferences.get_all(print_stdout=False)
print(f"{len(models)} models in the Inference Hub")
INFERENCE_ID = models[0]["id"]
model = client.inferences.get_by_id(INFERENCE_ID, print_stdout=False)
print("valid?", client.inferences.validate_id(INFERENCE_ID))
print("tags:", [t.get("displayName") for t in model.get("modelTags", [])])
print(client.inferences.get_all_public()) # models shared across organisations
You get back: the catalog, one model in full, and the public listing. Keep INFERENCE_ID and one entry from modelTags — serving needs both.
See which models are already running
What this does: lists the models currently being served, as opposed to the catalog above. If one you need is already up, you do not have to serve it again.
API GET /api/v1/spawned-inferences · /:id
running = client.inferences.get_all_spawned_inferences(print_stdout=False)
print(f"{len(running)} models currently served")
for r in running[:5]:
print(" -", r.get("id"), "|", r.get("name"), "|", r.get("status"))
INF_ID = running[0]["id"]
print("valid?", client.inferences.validate_spawned_inference_id(INF_ID))
print(client.inferences.get_spawned_inference_by_id(INF_ID, print_stdout=False))
You get back: the running models. status tells you whether one is usable — only a running model will answer a chat.
An empty list raises rather than returning []. When nothing at all is being served, this call raises NoResourcesError — that is the SDK's way of saying "none exist", not a failure. Wrap it in try if your script must cope with an empty platform.
Check what the model needs to start
What this does: shows the launch arguments the model will be started with — the --model=… path, how many GPUs to split across, and so on. The SDK sends these for you; this is how you see them beforehand.
API (read from the model tag — no extra call)
tag = model["modelTags"][0]
tag_params = tag.get("parameters") or {}
print("tag:", tag.get("displayName"), "| image:", tag.get("modelImage"))
print("launch arguments:")
for a in (tag_params.get("args") or model.get("args") or []):
print(f" {a.get('key')} = {a.get('value')}")
print("ports:", tag_params.get("ports") or model.get("ports") or "(defaults to HTTP 8000)")
You get back: the image, the launch arguments and the ports.
Without --model=… the model server starts with nothing to serve
and the pod fails a few minutes later. The SDK inherits these settings from the model tag automatically, the same as the Web UI — pass your own args only if you know what you are replacing.
Pick a plan that can actually run it
What this does: finds the plans that have a GPU. An LLM on a CPU-only plan is accepted by the platform and then never starts, which is a slow and confusing way to fail.
API GET /api/v1/plans
plans = client.plans.get_all(print_stdout=False)
gpu_plans = [p for p in plans if (p.get("resources") or {}).get("GPU", 0) >= 1]
print(f"{len(gpu_plans)} GPU plans available:")
for p in gpu_plans[:5]:
r = p["resources"]
print(f" {p['id']} {p['name']:22} CPU {r.get('CPU')} GPU {r.get('GPU')} RAM {r.get('memory')}GB")
INFERENCE_PLAN_ID = gpu_plans[0]["id"] if gpu_plans else PLAN_ID
# is a GPU actually free right now?
print(client.plans.capacity(DATACENTER_ID)["max"])
You get back: the GPU plans, and the one you will serve on.
A plan existing is not the same as a GPU being free. plans.capacity() tells you what is unused right now; a plan whose GPU is already taken will queue forever.
Serve a model (spawn)
What this does: starts a model as a live endpoint. The launch arguments come from the model automatically.
API POST /api/v1/spawned-inferences
models = client.inferences.get_all(print_stdout=False)
INFERENCE_ID = models[0]["id"]
model = client.inferences.get_by_id(INFERENCE_ID, print_stdout=False)
tag = model["modelTags"][0]
# LLMs need a GPU plan — pick one, or the model will never start
gpu_plans = [p for p in client.plans.get_all(print_stdout=False)
if (p.get("resources") or {}).get("GPU", 0) >= 1]
pod_name, url = client.inferences.spawn(
inference_name="my-llm-1",
inference_id=INFERENCE_ID,
plan_id=gpu_plans[0]["id"],
namespace_id=NAMESPACE_ID,
inference_display_name=tag["displayName"],
inference_model_image_name=tag["modelImage"],
inference_model_tag_id=tag["_id"],
model_type="VLLM",
)
You get back: the pod name and URL. It takes a few minutes to load the model.
A GPU plan is required. The SDK stops you with a clear error if the plan has 0 GPUs (pass allow_cpu_only=True to override). The platform itself does not check this — it would accept the request and the model would silently fail to start.
Launch arguments are automatic. --model=…, --tensor-parallel-size and friends are read from the model tag, exactly like the Web UI does. Without them the model has nothing to serve.
Manage the running model
What this does: find it, check it, and control it — the same handle used for every workload type.
API GET /spawned-inferences · PUT /:id/stop|start|restart · DELETE /:id
running = client.inferences.get_all_spawned_inferences(print_stdout=False)
INF_ID = running[0]["id"]
handle = client.spawned("inference", INF_ID)
print("status:", handle.status().get("status"))
print("usage:", handle.utilization("5m"))
handle.stop() # stop it (saves GPU cost)
handle.start() # start it again
handle.restart() # recreate the pod in place
handle.wait_until_ready(timeout=1800) # block until it reports running
handle.reconfigure(min_replicas=1, max_replicas=2, threshold=80)
handle.share(["teammate@yourcompany.com"]) # emails, usernames or ids
handle.delete(stop_first=True) # stops first, then deletes
You get back: the record for each call.
Reconfigure fields differ per type. Inferences use min_replicas/max_replicas/threshold; workstations and microservices use plan_id/resource_details; add-ons use min_workers/max_workers. Passing the wrong one fails immediately with the allowed list.
Delete needs the workload stopped. stop_first=True handles that for you.
Watch and control the running model
Start several models at once
What this does: serves more than one model in a single pass.
API POST /api/v1/spawned-inferences (once per model)
from zeblok.batch import unique_names, batch
names = unique_names("my-llm", 2,
existing=lambda: client.inferences.get_all_spawned_inferences(print_stdout=False))
result = batch(names, lambda n: client.inferences.spawn(
inference_name=n, inference_id=INFERENCE_ID, plan_id=INFERENCE_PLAN_ID,
namespace_id=NAMESPACE_ID, inference_display_name=tag["displayName"],
inference_model_image_name=tag["modelImage"], inference_model_tag_id=tag["_id"],
model_type="VLLM"))
print(result.summary())
You get back: a result object naming what worked and what did not.
Each model needs its own GPU. If the cluster cannot fit them all, the ones that do not fit are reported individually instead of the whole batch dying.
Monitor it
What this does: the read-only checks while a model is being served.
API GET /spawned-inferences/:id · /:id/utilization · pod logs
handle = client.spawned("inference", INF_ID)
print("state:", handle.status().get("status"))
print(handle.utilization("5m")) # GPU / CPU / memory
print(handle.logs()) # the model server's own output
You get back: the record, usage figures and the logs.
The logs are the first place to look when a model sits in starting for a long time — a large model can spend many minutes pulling and loading before it serves anything.
replica_status() is microservice-only, so it reports as unavailable here.
Stop, start, restart and wait
What this does: the lifecycle controls. Stopping a served model frees its GPU, which is usually the most expensive thing you have running.
API PUT /spawned-inferences/:id/stop | start | restart
handle.stop() # frees the GPU
handle.start()
handle.restart()
handle.wait_until_ready(timeout=1800) # big models load slowly — allow 30 min
print("state:", handle.status().get("status"))
You get back: the platform's response to each, then the state once it settles.
Stop models you are not using. A served model holds its GPU whether or not anyone is chatting with it.
Resize it (reconfigure)
What this does: changes how many copies serve the model and when it scales up.
API PUT /spawned-inferences/:id/reconfigure
handle.reconfigure(min_replicas=1, max_replicas=2, threshold=80)
You get back: the updated record.
Models take different fields from every other workload. min_replicas / max_replicas / threshold — not updated_replicas, and not plan_id. Passing the wrong one is refused straight away with the list of what is allowed.
See who you can share with, and share
What this does: lists the people visible to you, then gives one of them access to the served model.
API GET /api/v1/users · PUT /spawned-inferences/:id/share
for u in (client.users.list() or []):
print(" ", u.get("email"), "|", u.get("username"), "|", u.get("_id"))
handle.share(["colleague@yourcompany.com"]) # emails, usernames or ids
You get back: the list of people, then the updated record with them in allowedUsers.
Delete it
What this does: removes the served model permanently and frees its GPU.
API DELETE /spawned-inferences/:id
from zeblok.utils.errors import NoResourcesError
handle.delete(stop_first=True)
try:
remaining = client.inferences.get_all_spawned_inferences(print_stdout=False) or []
except NoResourcesError:
remaining = [] # nothing served at all — the delete worked
print("still listed?", any(s.get("id") == INF_ID for s in remaining)) # False
You get back: confirmation, then False proving it is gone.
Deleting the last served model makes the list call raise NoResourcesError instead of returning an empty list. Catch it, as above — it means the delete succeeded, not that something went wrong.
This removes the running model, not the catalog entry. The model stays in the Hub and can be served again.
Find out which model name to send
What this does: asks the deployment what it actually calls itself. This is the single most common cause of a chat failing against a model that is running perfectly well.
API GET <model-host>/v1/models
from zeblok.llm import InferenceChat
chat = InferenceChat(client.auth, spawned_inference_id=INF_ID, ai_key=AI_KEY)
print("this deployment serves:", chat.served_models())
You get back: the exact model name(s) the deployment accepts.
Easiest fix: do not pass a name at all. Leave model_name out of chat() and the SDK asks the deployment and uses what it says.
The served name is not the friendly catalog name.
A model registers itself under the path it was launched with — something like /home/app/models/meta-llama/Llama-3.1-8B-Instruct — not Llama 3.1 8B Instruct. Sending the friendly name makes the model answer 404, which the platform reports back as 503 model is offline or unreachable. The model is fine; the name was wrong.
Chat with the model
What this does: sends a prompt and returns the model's reply.
API POST /api/v1/spawned-inferences/:id/chat/sdk (Bearer Gen-AI key)
from zeblok.llm import InferenceChat
AI_KEY = "<your zbl_… Gen-AI key>" # Web UI -> API Keys & Secrets -> Gen AI
chat = InferenceChat(client.auth, spawned_inference_id=INF_ID, ai_key=AI_KEY)
print(chat.served_models()) # exact model name(s) this deployment accepts
print(chat.chat(prompt="Say hello in one short sentence."))
chat.get_all() # chat history with token counts
You get back: the model's reply as text.
Chat uses the Gen-AI key (zbl_…), not your platform key pair.
You do not need to know the model name.
Leave model_name out and the SDK asks the deployment. The served name is usually an internal path like /home/app/models/meta-llama/Llama-3.1-8B-Instruct, not the friendly catalog name — using the wrong one makes the platform report a misleading 503 model is offline.
The model must be running. If it is stopped, start it first.
Everything after it is started — status, logs, the open link, stop, start, restart, resize, share and delete — works the same for every workload type and is described once in Managing what you started.
Workspace › Agents
An AI assistant with tools, running on the platform and ready to talk to.
Steps: 1. Pick a brain + tools → 2. Spawn → 3. Chat → 4. Watch tools → 5. Delete
This page is about running agents. The templates they are started from live in Imports › Agent Hub.
| What you want to do | Code | API |
|---|---|---|
| Start an agent | client.agents.spawn(...) |
POST /api/v1/spawned-agents |
| List running agents | client.agents.spawned() |
GET /api/v1/spawned-agents |
| Check status / usage | sa.status() · sa.usage() |
GET /spawned-agents/:id[/usage] |
| See its tools | sa.mcp().tools() |
GET /mcp-discovery/:id/tools |
| Talk to it (streams) | sa.chat(message, ai_key) |
POST /spawned-agents/:id/chat |
| Stop / Start | sa.stop() · sa.start() |
`PUT /spawned-agents/:id/stop |
| Change settings | sa.reconfigure(...) |
PUT /spawned-agents/:id/reconfigure |
| Share it | sa.share([...]) |
PUT /spawned-agents/:id/share |
| Delete it | sa.delete() |
DELETE /spawned-agents/:id |
Before you spawn: list what is running, and pick a brain
List running agents
What this does: shows the agents already deployed, and which of them may be called as sub-agents by another agent.
API GET /api/v1/spawned-agents · /spawned-agents/callable-sub-agents
running = client.agents.spawned()
for a in running[:5]:
print(" -", a.get("_id"), "|", a.get("name"), "|", a.get("status"))
AGENT_ID = running[0]["_id"] if running else None
print(client.agents.callable_sub_agents()) # agents other agents may delegate to
You get back: the running agents, and the sub-agent-capable subset.
Only agents spawned with is_callable_as_sub_agent=True appear in the second list — that flag is what makes an agent delegatable.
Pick the model it will think with, and the tools it may use
What this does: an agent needs exactly one model behind it. This finds a running one on the platform, and lists the MCP server templates you can attach as its tools. Run this before spawning.
API GET /api/v1/spawned-inferences · GET /api/v1/agents
# 1 — a brain. Prefer a model already RUNNING on the platform.
try:
infs = client.inferences.get_all_spawned_inferences(print_stdout=False) or []
except Exception:
infs = []
running_models = [i for i in infs if str(i.get("status", "")).lower() == "running"]
AGENT_BRAIN_ID = (running_models[0].get("_id") or running_models[0].get("id")) if running_models else None
# ...or an outside provider instead
AGENT_EXTERNAL = None
# AGENT_EXTERNAL = {"provider": "anthropic", # anthropic | openai | xai | custom
# "model": "claude-sonnet-4-5",
# "apiKey": "<your-provider-key>"} # + "baseUrl" when provider="custom"
print("brain:", AGENT_BRAIN_ID or AGENT_EXTERNAL or "NONE — spawning will fail")
# 2 — the tools. MCP server templates from the Agent Hub.
servers = [a for a in (client.agents.get_all() or []) if a.get("type") == "server"]
for t in servers[:5]:
print(" server template:", t.get("_id"), "|", t.get("name"))
AGENT_SERVERS = [] # e.g. [servers[0]["_id"]]
You get back: the id of a running model to use as the brain, and the MCP templates you can attach.
Leave AGENT_SERVERS empty for an orchestrator agent whose tools are other agents rather than MCP servers.
Exactly one brain, no more and no less.
Passing neither spawned_inference_model_id nor external_inference — or passing both — is rejected. On the platform side that comes back as a bare 400 Validation failed; the SDK checks first and tells you which mistake you made.
The model must be running. A stopped one is refused with INFERENCE_NOT_RUNNING.
Start an agent
What this does: deploys a running agent. It needs somewhere to run and exactly one model to think with — a running inference on the platform, or an external provider. Find one with client.inferences.get_all_spawned_inferences() and pick an entry whose status is running.
API POST /api/v1/spawned-agents
created = client.agents.spawn(
"my-running-agent",
namespace_id=NAMESPACE_ID,
datacenter_id=DATACENTER_ID,
plan_id=PLAN_ID,
servers=[MCP_SERVER_ID], # MCP server ids it can use;
# their ports/envs/args are
# inherited automatically.
# Empty = orchestrator agent.
system_prompt="You are a helpful assistant.",
# give it a brain — EXACTLY ONE of these two is required.
# Neither (or both) is rejected with "400 Validation failed".
spawned_inference_model_id="<spawned-inference-id>", # must be RUNNING
# external_inference={"provider": "anthropic", # or an external provider
# "model": "claude-…", # openai | xai | custom
# "apiKey": "<key>"}, # + baseUrl for "custom"
)
AGENT_ID = created["_id"]
You get back: the created agent record.
servers can be empty. An orchestrator agent whose tools are other agents needs no MCP servers — pass available_sub_agents=[...] instead.
Stop an agent before deleting it.
A running agent cannot be deleted, and the platform reports that as "User not authenticated (Please stop the running Agent First)" — confusing, because nothing is wrong with your key. Use sa.delete(stop_first=True) and the SDK stops it, waits, then deletes.
Agents cannot be restarted. The platform has no restart endpoint for them — stop and start instead.
Agent chat streams.
Unlike inference chat, this endpoint sends the reply token by token. sa.chat(...) collects the whole thing and returns it as a string, so it looks the same as any other call:
print(sa.chat("What can you do?", ai_key=AI_KEY))
To show the answer as it is typed, pass a callback — you also see tool_call and tool_result events as the agent uses its tools:
sa.chat("List your tools", ai_key=AI_KEY, on_event=lambda e, d: print(d.get("text", ""), end="") if e == "text" else None)
Use raw=True to get every event instead of the text.
Use and manage a running agent
What this does: check it, see its tools, talk to it, and control it.
API GET /spawned-agents · /mcp-discovery/:id/tools · POST /:id/chat
running = client.agents.spawned()
sa = client.spawned_agent(running[0]["_id"])
print(sa.status())
print(sa.usage()) # tokens used
print(sa.guardrail_events()) # anything the safety rules blocked
print(sa.mcp().tools()) # the tools this agent can use
print(sa.chat("Hello! What can you do?", ai_key=AI_KEY)) # Gen-AI key
sa.reconfigure(system_prompt="Be concise.", context_length=8000)
sa.share(["teammate@yourcompany.com"])
sa.stop(); sa.start()
sa.delete()
You get back: status, usage, tool list, the agent's reply, and the result of each action.
Chat uses the Gen-AI key (zbl_…), the same as model chat.
Read, control and remove a running agent
Spawn several agents at once
What this does: deploys more than one agent in a single pass. Each still needs exactly one brain.
API POST /api/v1/spawned-agents (once per agent)
from zeblok.batch import unique_names, batch
names = unique_names("my-agent", 2, existing=client.agents.spawned)
result = batch(names, lambda n: client.agents.spawn(
n, namespace_id=NAMESPACE_ID, datacenter_id=DATACENTER_ID, plan_id=PLAN_ID,
servers=AGENT_SERVERS, system_prompt="You are a helpful assistant.",
spawned_inference_model_id=AGENT_BRAIN_ID,
external_inference=None if AGENT_BRAIN_ID else AGENT_EXTERNAL))
print(result.summary())
You get back: a result object listing what worked.
Read its sessions, token spend and guardrail events
What this does: everything the platform records about a running agent — the conversations it has had, what it has cost in tokens, and anything its safety rules blocked.
API GET /spawned-agents/:id[/sessions | /usage | /guardrail-events | /chat/archived]
sa = client.spawned_agent(AGENT_ID)
print(sa.status())
print(sa.sessions()) # conversations
print(sa.usage()) # tokens spent
print(sa.guardrail_events()) # anything the safety rules stopped
print(sa.archived_chat()) # older conversations
# past conversations for one person
# sa.chat_history(user_id="<user-id>")
You get back: the record, the session list, token counts, blocked events and archived chats.
usage() is how you find out what an agent is costing — it counts tokens against the Gen-AI keys used to talk to it.
See the tools it exposes
What this does: lists what the agent can actually do. If this is empty, the agent has no MCP servers attached and can only answer from the model's own knowledge.
API GET /api/v1/mcp-discovery/:id/tools | resources | prompts
print(sa.mcp().tools())
print(sa.mcp().resources())
print(sa.mcp().prompts())
You get back: the tool, resource and prompt lists the agent's MCP servers advertise.
An error saying "Spawned agent has no MCP servers" means it was spawned with an empty servers=[]. That is correct for an orchestrator agent, and wrong for everything else.
Stop, start, reconfigure and update
What this does: the lifecycle controls, plus the two ways to change an agent's behaviour after it is running.
API PUT /spawned-agents/:id/stop | start · /:id/reconfigure · PUT /:id
sa.stop()
sa.start()
sa.reconfigure(context_length=8000)
sa.update(system_prompt="You are a concise assistant.")
# both accept: system_prompt, servers, context_length,
# available_sub_agents, spawned_inference_model_id, ...
You get back: the platform's response to each.
Agents cannot be restarted. The platform has no restart route for them, so the SDK raises a clear error instead of sending a request that would fail. Stop and start instead.
Share it with a colleague
What this does: gives someone else access to the agent.
API PUT /api/v1/spawned-agents/:id/share
print([u["email"] for u in (client.users.list() or [])])
sa.share(["colleague@yourcompany.com"])
You get back: the updated record.
Delete it
What this does: removes the running agent permanently.
API DELETE /api/v1/spawned-agents/:id
sa.delete(stop_first=True)
left = [a.get("_id") for a in (client.agents.spawned() or [])]
print("still listed?", AGENT_ID in left) # False
You get back: confirmation, then False proving it is gone.
A running agent cannot be deleted, and the error is misleading.
The platform answers 401 "Please stop the running Agent First" — a business rule sent with an authentication status code, so it looks like your credentials broke when they did not. stop_first=True avoids it entirely.
Watch the agent use a tool
What this does: lists the agent's tools, runs one directly, and shows the agent picking a tool on its own. The agent must have been spawned with servers=[...] — one with none replies "Spawned agent has no MCP servers".
API GET /api/v1/mcp-discovery/:id/tools · POST /api/v1/mcp-discovery/:id/tools/call
tools = sa.mcp().tools()
for t in tools:
print(t["name"], "-", t.get("description"))
# run a tool yourself, without the model — the fastest way to prove
# the MCP server is reachable from the cluster
print(sa.mcp().call_tool("ask_question", {
"repoName": "modelcontextprotocol/servers",
"question": "What is this repo?",
}))
# or ask the agent, and watch it decide to call the tool
def show(event, data):
if event == "tool_call":
print("\\n-> calls", data["name"], data.get("arguments"))
elif event == "tool_result":
print("<- answered in", data.get("durationMs"), "ms")
elif event == "text":
print(data.get("text", ""), end="")
sa.chat("Use your tools: what does that repo do?", ai_key=AI_KEY, on_event=show)
You get back: the tool list, the tool's own answer, then the agent's answer with the tool calls shown as they happen. No tool calls means the model answered from memory — ask something it cannot know without the tool.
Make one agent use another (sub-agents)
What this does: builds a child that does the real work and a parent that delegates to it. The parent has no tools of its own — its tools are its children.
API POST /api/v1/spawned-agents · GET /spawned-agents/callable-sub-agents · POST /:id/chat/cancel-subagent
# 1. the child — the flag is what makes it delegatable
child = client.agents.spawn(
"repo-expert", namespace_id=NS, datacenter_id=DC, plan_id=PLAN,
servers=[MCP_SERVER_ID],
spawned_inference_model_id=INFERENCE_ID,
is_callable_as_sub_agent=True,
sub_agent_description="Answers questions about public GitHub repositories.",
)
# 2. the parent — no servers, just children
parent = client.agents.spawn(
"coordinator", namespace_id=NS, datacenter_id=DC, plan_id=PLAN,
servers=[],
spawned_inference_model_id=INFERENCE_ID,
system_prompt="Delegate repository questions to your sub-agent.",
available_sub_agents=[child["_id"]],
sub_agent_config={"timeoutMs": 120000, "maxIterations": 5},
)
# 3. talk to the parent and watch it hand the work over
calls = []
def show(event, data):
if event == "subagent_start":
calls.append(data["callId"])
print("-> delegates to", data.get("subAgent"))
elif event == "subagent_done":
print("<- child finished:", data.get("success"))
client.spawned_agent(parent["_id"]).chat(
"Ask your sub-agent what that repo does.", ai_key=AI_KEY, on_event=show)
# stop a long delegation (from another thread — the parent keeps streaming)
client.spawned_agent(parent["_id"]).cancel_subagent(callId=calls[-1])
You get back: the parent's answer, with each delegation shown as it happens.
The description is the trigger. The parent's model decides when to delegate by reading sub_agent_description — write it like a tool description. No delegation usually means that text is too vague.
Tell the parent to stop.
If the parent's prompt only says "delegate", the model keeps handing the same question over and the turn fails with Tool-loop exceeded 10 iterations without producing a final answer. Say "call the sub-agent at most once, then write the final answer and stop". Two more lines matter: tell the parent to repeat the question in full when it delegates (the child cannot see the conversation, so it often replies "that is not specified" and the parent retries forever), and to accept an incomplete answer rather than try again.
If it still fails, the SDK keeps the work: completed delegations are on the exception as .subagent_results, so the child's answer is not lost.
Note there are two iteration limits: sub_agent_config={"maxIterations": ...} limits the child, while the parent is capped at 10 and can only be raised by linking an agent type with a higher limit. Raising the child's limit will not fix a looping parent.
No nesting. A sub-agent cannot have sub-agents: the platform caps delegation depth at one level. At most 25 sub-agent calls are allowed in a single chat turn.
Only flagged agents can be called. client.agents.callable_sub_agents() lists them — an agent spawned without is_callable_as_sub_agent=True will not appear and cannot be delegated to.
Agents use their own handle, client.spawned_agent(id), but the ideas are the same as every other workload — see Managing what you started.
Imports › Workstations
The catalog of workstation images people can start a workstation from.
Steps: 1. Browse catalog → 2. Add image → 3. Update → 4. Delete
Two things share the name. The Imports page holds templates — the catalog entry describing what something is. The Workspace page holds the running copies you started from a template. This section is the catalog; starting one is in Workspace › Workstations.
In the SDK these live under client.workstations, and the platform stores them at /docker-images — the same object handles both the catalog and the running copies.
| What you want to do | Code | API |
|---|---|---|
| See the catalog | client.workstations.get_all() |
GET /api/v1/docker-images |
| See one template | client.workstations.get_by_id(id) |
GET /api/v1/docker-images/:id |
| Check it exists | client.workstations.validate_id(id) |
GET /api/v1/docker-images/:id |
| See the public marketplace | client.workstations.get_all_public() |
GET /api/v1/docker-images/public |
| Add a template | client.workstations.create(...) |
POST /api/v1/docker-images |
| Change a template | client.workstations.update(id, ...) |
PUT /api/v1/docker-images/:id |
| Remove a template | client.workstations.delete(id) |
DELETE /api/v1/docker-images/:id |
| Accept a licence | client.workstations.accept_license(...) |
PUT /api/v1/docker-images/:id/accept-license |
Browse the catalog
What this does: lists the images you can start a workstation from, and shows the image tags one of them offers. You need one of those tag names to start anything.
API GET /api/v1/docker-images · /:id · /public
catalog = client.workstations.get_all(print_stdout=False)
print(f"{len(catalog)} workstation images available")
WORKSTATION_ID = catalog[0]["id"]
ws = client.workstations.get_by_id(WORKSTATION_ID, print_stdout=False)
print("name:", ws["name"])
print("image tags:", ws.get("display_names")) # spawn() needs one of THESE names
print("exists?", client.workstations.validate_id(WORKSTATION_ID))
# images shared across organisations
print(f"{len(client.workstations.get_all_public())} public images")
You get back: the catalog list, one full template, and the public listing.
display_names is the list that matters. When you start a workstation you pass display_name=ws["display_names"][0] — a name that is not in this list is rejected.
Add your own image to the catalog
What this does: registers a Docker image so you and your colleagues can start workstations from it.
API POST /api/v1/docker-images
entry = client.workstations.create(
"my-lab", # name — 1 to 50 characters
"JupyterLab with my team's tools", # description — required
[{"displayName": "my-lab:1.0", # one or more image tags
"dockerImage": "myorg/my-lab:1.0"}],
"open source", # licence: "open source" or "third party"
visibility="private", # "private", "organization" or "public"
# is_algorithm=False, # workstation-only extras
# is_slurm=False,
# command="start-notebook.sh",
# plans=[PLAN_ID], default_plan=PLAN_ID, # plans it may run on
)
WS_TEMPLATE_ID = entry["_id"]
print("template id:", WS_TEMPLATE_ID)
You get back: the created entry. Save _id.
Rules the SDK checks before sending:
| Rule | Why |
|---|---|
| Name is 1–50 characters | The platform's own limit. |
| Description cannot be empty | Required by the platform. |
Every image tag needs displayName and dockerImage |
One names it for people, the other tells the platform what to pull. |
Licence is open source or third party |
Only these two are accepted. |
Visibility is private, organization or public |
Anything else is rejected. |
Using a private registry? Pass private_repo=True with docker_username, docker_password, docker_email and docker_url.
Change or remove a template
What this does: edits an entry (send only what you want changed), or removes it from the catalog.
API PUT · DELETE /api/v1/docker-images/:id
client.workstations.update(WS_TEMPLATE_ID, description="now with PyTorch 2.4")
# add another tag — the list REPLACES the old one, so include what you keep
client.workstations.update(WS_TEMPLATE_ID, image_tags=[
{"displayName": "my-lab:1.0", "dockerImage": "myorg/my-lab:1.0"},
{"displayName": "my-lab:2.0", "dockerImage": "myorg/my-lab:2.0"},
])
client.workstations.delete(WS_TEMPLATE_ID)
print("gone?", not client.workstations.validate_id(WS_TEMPLATE_ID))
You get back: the updated entry, then True once deleted.
Removing a template does not stop workstations already running from it. Those carry on until you delete them separately.
For a licensed image, record acceptance first:
client.workstations.accept_license(WS_TEMPLATE_ID, container_id, user_id)
Register several images at once
What this does: adds more than one workstation image to the catalog in a single pass.
API POST /api/v1/docker-images (once per template)
from zeblok.batch import unique_names, batch, delete_many
names = unique_names("my-lab", 2, existing=lambda: client.workstations.get_all(print_stdout=False))
result = batch(names, lambda n: client.workstations.create(
n, "registered by a script",
[{"displayName": "busybox:latest", "dockerImage": "busybox:latest"}],
"open source", visibility="private"))
print(result.summary())
# clean up: delete_many(result, lambda i: client.workstations.delete(i))
You get back: a result object listing what was created and what failed.
Imports › Microservices
The catalog of application images people can deploy as a microservice.
Steps: 1. Browse catalog → 2. Add template → 3. Update → 4. Delete
Two things share the name. The Imports page holds templates — the catalog entry describing what something is. The Workspace page holds the running copies you started from a template. This section is the catalog; starting one is in Workspace › Microservices.
| What you want to do | Code | API |
|---|---|---|
| See the catalog | client.microservices.get_all() |
GET /api/v1/microservices |
| See one template | client.microservices.get_by_id(id) |
GET /api/v1/microservices/:id |
| Check it exists | client.microservices.validate_id(id) |
GET /api/v1/microservices/:id |
| See the public marketplace | client.microservices.get_all_public() |
GET /api/v1/microservices/public |
| Add a template | client.microservices.create(...) |
POST /api/v1/microservices |
| Change a template | client.microservices.update(id, ...) |
PUT /api/v1/microservices/:id |
| Remove a template | client.microservices.delete(id) |
DELETE /api/v1/microservices/:id |
| Accept a licence | client.microservices.accept_license(...) |
PUT /api/v1/microservices/:id/accept-license |
Browse the catalog and read its defaults
What this does: lists what you can deploy, and shows the ports and environment variables a template ships with. Those defaults are what the Web UI pre-fills into the deploy form — and what the SDK inherits when you leave them out.
API GET /api/v1/microservices · /:id · /public
catalog = client.microservices.get_all(print_stdout=False)
print(f"{len(catalog)} microservices in the catalog")
MICROSERVICE_ID = catalog[0]["id"]
ms = client.microservices.get_by_id(MICROSERVICE_ID, print_stdout=False)
print("image tags:", ms.get("display_names")) # spawn() needs one of THESE
print("defaults :", ms.get("parameters")) # ports / envs / args / volumePath
print(f"{len(client.microservices.get_all_public())} public microservices")
You get back: the catalog, one full template including its parameters, and the public listing.
Read parameters before deploying. If you pass no ports or envs when you start the service, these are used. If you pass your own, yours replace them entirely.
Add your own application to the catalog
What this does: registers a Docker image as something people can deploy.
API POST /api/v1/microservices
entry = client.microservices.create(
"my-api", # name — 1 to 50 characters
"Our internal orders API", # description — required
[{"displayName": "my-api:1.0", # one or more image tags
"dockerImage": "myorg/my-api:1.0"}],
"open source", # "open source" or "third party"
visibility="private",
# the defaults people get when they deploy it:
parameters={
"ports": [{"protocol": "HTTP", "portIdentifier": "http", "number": 8080}],
"envs": [{"key": "LOG_LEVEL", "value": "info"}],
},
# plans=[PLAN_ID], default_plan=PLAN_ID,
# is_vector_db=False,
)
MS_TEMPLATE_ID = entry["_id"]
You get back: the created entry. Save _id.
Setting parameters here saves everyone time later. Whoever deploys the template gets your ports and environment variables without having to know them.
Change or remove a template
What this does: edits an entry, or takes it out of the catalog.
API PUT · DELETE /api/v1/microservices/:id
client.microservices.update(MS_TEMPLATE_ID, description="now with rate limiting")
client.microservices.update(MS_TEMPLATE_ID, visibility="organization")
client.microservices.delete(MS_TEMPLATE_ID)
print("gone?", not client.microservices.validate_id(MS_TEMPLATE_ID))
You get back: the updated entry, then True once deleted.
You can change name, description, image_tags, parameters, visibility, plans, default_plan, update_repo and the private-registry fields. Passing a name the SDK does not recognise gives you the full list of allowed ones.
Register several templates at once
What this does: adds more than one microservice template to the catalog in a single pass.
API POST /api/v1/microservices (once per template)
from zeblok.batch import unique_names, batch, delete_many
names = unique_names("my-svc-tpl", 2, existing=lambda: client.microservices.get_all(print_stdout=False))
result = batch(names, lambda n: client.microservices.create(
n, "registered by a script",
[{"displayName": "busybox:latest", "dockerImage": "busybox:latest"}],
"open source", visibility="private"))
print(result.summary())
# clean up: delete_many(result, lambda i: client.microservices.delete(i))
You get back: a result object listing successes and failures.
These are catalog entries, not running services — nothing is deployed and nothing costs resources until someone spawns from them.
Imports › Orchestration Add-ons
The catalog of cluster tools, such as a Ray cluster.
Steps: 1. Browse catalog → 2. Add template → 3. Update → 4. Delete
Two things share the name. The Imports page holds templates — the catalog entry describing what something is. The Workspace page holds the running copies you started from a template. This section is the catalog; starting one is in Workspace › Orchestration Add-on.
| What you want to do | Code | API |
|---|---|---|
| See the catalog | client.orchestrations.get_all() |
GET /api/v1/k8s-addons |
| See one template | client.orchestrations.get_by_id(id) |
GET /api/v1/k8s-addons/:id |
| Check it exists | client.orchestrations.validate_id(id) |
GET /api/v1/k8s-addons/:id |
| See public add-ons | client.orchestrations.get_all_public() |
GET /api/v1/k8s-addons/public |
| Add a template | client.orchestrations.create(...) |
POST /api/v1/k8s-addons |
| Change a template | client.orchestrations.update(id, ...) |
PUT /api/v1/k8s-addons/:id |
| Remove a template | client.orchestrations.delete(id) |
DELETE /api/v1/k8s-addons/:id |
Browse the add-on catalog
What this does: lists the cluster tools you can deploy, and opens one up.
API GET /api/v1/k8s-addons · /:id · /public
catalog = client.orchestrations.get_all(print_stdout=False)
print(f"{len(catalog)} add-ons in the catalog")
ADDON_ID = catalog[0]["id"]
print(client.orchestrations.get_by_id(ADDON_ID, print_stdout=False))
print("valid?", client.orchestrations.validate_id(ADDON_ID))
print(f"{len(client.orchestrations.get_all_public())} public add-ons")
You get back: the catalog, one template in full, and the public listing. Keep ADDON_ID — deploying needs it.
Add an add-on to the catalog
What this does: registers a cluster tool so people can deploy it.
API POST /api/v1/k8s-addons
entry = client.orchestrations.create(
"my-ray", # name — 1 to 100 characters
"rayproject/ray:2.9.0", # image_tag — a single STRING, not a list
description="Ray cluster for the data team",
visibility="private",
# head_plans=[PLAN_ID], # plans allowed for the head node
# worker_plans=[PLAN_ID], # plans allowed for the workers
# default_plan=PLAN_ID,
# is_active=True,
)
OA_TEMPLATE_ID = entry["_id"]
print("template id:", OA_TEMPLATE_ID)
You get back: the created entry. Save _id.
Head and worker plans are separate because the two do different jobs — the head node coordinates, the workers do the computing, and they rarely want the same size.
This is where add-ons differ from every other catalog.
Workstations and microservices take image_tags=[{"displayName": …, "dockerImage": …}]. Add-ons take image_tag="org/image:tag" — a single string. Passing a list here is the most common mistake in this section.
Change an add-on template
What this does: edits an entry. Send only what you want changed.
API PUT /api/v1/k8s-addons/:id
client.orchestrations.update(OA_TEMPLATE_ID, description="updated by a script")
client.orchestrations.update(OA_TEMPLATE_ID, visibility="organization")
# retire it without deleting it
client.orchestrations.update(OA_TEMPLATE_ID, is_active=False)
You get back: the updated entry.
is_active=False is the gentle option. It takes the add-on out of circulation while leaving the record — better than deleting when you are retiring something people may still be using.
Fields you can change: name, description, image_tag, visibility, is_public, is_active, s3_image_link, default_plan, head_plans, worker_plans.
Remove an add-on template
What this does: deletes the catalog entry and confirms it is gone.
API DELETE /api/v1/k8s-addons/:id
client.orchestrations.delete(OA_TEMPLATE_ID)
print("gone?", not client.orchestrations.validate_id(OA_TEMPLATE_ID)) # True
You get back: confirmation, then True once it no longer resolves.
Clusters already running from this template keep running. Deleting the template only stops new ones being deployed from it.
There is no licence step for add-ons. Unlike workstations and microservices they have no accept-licence route, so accept_license() tells you so rather than sending a request that would fail.
Register several add-on templates at once
What this does: adds more than one add-on to the catalog in a single pass.
API POST /api/v1/k8s-addons (once per template)
from zeblok.batch import unique_names, batch, delete_many
names = unique_names("my-addon", 2, existing=lambda: client.orchestrations.get_all(print_stdout=False))
result = batch(names, lambda n: client.orchestrations.create(
n, "busybox:latest", # remember: a single image STRING
description="registered by a script", visibility="private"))
print(result.summary())
# clean up: delete_many(result, lambda i: client.orchestrations.delete(i))
You get back: a result object listing what was created.
Note the image_tag is still a single string here, even in bulk.
Imports › Model Hub
Bring a model from HuggingFace into the platform so you can serve it.
Steps: 1. Preview the model → 2. Import → 3. Watch the build → 4. Serve it
| What you want to do | Code | API |
|---|---|---|
| See recommended models | client.models.popular_models() |
GET /api/v1/inferences/popular-models |
| Preview a model before importing | client.models.metadata(model_id) |
POST /api/v1/inferences/model-metadata |
| Browse your private models | client.models.private_models(hf_token) |
POST /api/v1/inferences/private-models |
| Import a model | client.models.import_model(...) |
POST /api/v1/inferences |
| Retry a failed import | client.models.retry(id) |
POST /api/v1/inferences/:id/retry-import |
| See imported models | client.inferences.get_all() |
GET /api/v1/inferences |
| Change an imported model | client.models.update(id, ...) |
PUT /api/v1/inferences/:id |
| Delete an imported model | client.models.delete(id) |
DELETE /api/v1/inferences/:id |
Look before you import
What this does: shows the models the platform recommends, and lets you check one model's size and licence before importing it. Nothing is downloaded.
API GET /api/v1/inferences/popular-models · POST /api/v1/inferences/model-metadata
for m in client.models.popular_models()[:5]:
print(m["id"], "-", m["name"])
info = client.models.metadata("Qwen/Qwen2.5-0.5B-Instruct")
print(info) # size, files, licence — so you know if it fits your plan
You get back: a list of recommended models, and details of the one you asked about.
Import a model
What this does: brings the model into the platform: it downloads the model and builds an image you can serve. This takes time and uses storage.
API POST /api/v1/inferences
result = client.models.import_model(
model_id="Qwen/Qwen2.5-0.5B-Instruct", # the HuggingFace model name
hf_token="<your-huggingface-token>",
name="my-qwen",
model_type="VLLM",
)
MODEL_ID = result["_id"]
print("Import started:", MODEL_ID)
You get back: the created model record.
Watch progress in the Web UI under Imports. If it fails, use client.models.retry(MODEL_ID).
Change or delete an imported model
What this does: renames the model, changes who can see it, or removes it from the platform.
API PUT /api/v1/inferences/:id · DELETE /api/v1/inferences/:id
client.models.update(MODEL_ID, description="My tuned model", visibility="private")
client.models.delete(MODEL_ID)
print("Gone?", not client.inferences.validate_id(MODEL_ID))
You get back: the updated model, then True after deleting.
Browse your own private HuggingFace models
What this does: lists the private or gated HuggingFace repositories your token can see, so you can import one of them. The platform queries HuggingFace on your behalf.
API POST /api/v1/inferences/private-models
import os
HF_TOKEN = os.environ.get("HF_TOKEN", "")
if HF_TOKEN:
mine = client.models.private_models(HF_TOKEN, limit=10)
print(mine)
else:
print("set HF_TOKEN to browse your private repositories")
# narrow it down
# client.models.private_models(HF_TOKEN, search="llama", page=1, limit=20)
You get back: your private and gated repositories, as import candidates.
Only needed for private or gated models. Public models can be imported by name without a token.
limit is capped at 100.
Re-run a failed import
What this does: kicks off an import again after it failed, without registering the model a second time.
API POST /api/v1/inferences/:id/retry-import
client.models.retry(MODEL_ID)
You get back: confirmation that the import restarted.
Check the build log first so you know what went wrong — retrying an import that failed for a real reason (a gated licence you have not accepted, a model too big for the disk) just fails again. See Containerization.
Common causes: the HuggingFace token lacks access, the repository is gated, or the model is larger than the storage available.
Operations hooks
What this does: two calls you will not use day to day, but which matter when an import gets stuck or a model has been shared too widely.
API POST /api/v1/inferences/:id/force-unshare · PUT /:id/vault-status
# strip every share from a model in one go (admin clean-up)
client.models.force_unshare(MODEL_ID)
# report download progress — normally the platform's own downloader calls this.
# Exposed for operations work, e.g. clearing a stuck import.
client.models.vault_status(MODEL_ID, "FAILED", reason="Gated repo — licence not accepted")
You get back: confirmation for each.
vault_status() writes the platform's own bookkeeping.
Normal imports call it themselves; setting it by hand is for unsticking an import that never finished. The status must be one of READY, DOWNLOADING, QUEUED or FAILED — the SDK checks before sending.
Imports › Agent Hub
The catalog of agent templates — what an agent is, before you run one.
Steps: 1. Browse Hub → 2. Add template → 3. Set ports / plans → 4. Delete
Two things share the name. The Imports page holds templates — the catalog entry describing what something is. The Workspace page holds the running copies you started from a template. This section is the catalog; starting one is in Workspace › Agents.
Agent templates come in two flavours. A container agent is an image the platform runs for you. An external agent already runs somewhere else and the template just points at its URL.
| What you want to do | Code | API |
|---|---|---|
| Browse templates | client.agents.get_all() |
GET /api/v1/agents |
| See public templates | client.agents.public() |
GET /api/v1/agents/public |
| See one template | client.agents.get_by_id(id) |
GET /api/v1/agents/:id |
| See agent types | client.agents.types() |
GET /api/v1/agent-types |
| Add a template | client.agents.create(...) |
POST /api/v1/agents |
| Change a template | client.agents.update(id, ...) |
PUT /api/v1/agents/:id |
| Remove a template | client.agents.delete(id) |
DELETE /api/v1/agents/:id |
Browse the Hub
What this does: lists the agent templates you can spawn from, and opens one up. These are definitions, not running agents.
API GET /api/v1/agents · /agents/public · /agents/:id
templates = client.agents.get_all()
print(f"{len(templates)} agent templates in the Hub")
print(f"{len(client.agents.public())} public templates")
AGENT_TEMPLATE_ID = templates[0].get("_id") or templates[0].get("id")
print(client.agents.get_by_id(AGENT_TEMPLATE_ID))
You get back: the template list and one full template.
See the agent types
What this does: lists the runtime presets an agent can be linked to. The type decides how the agent behaves, and can be passed as agent_type_id when you spawn one.
API GET /api/v1/agent-types · /agent-types/:slug
print(client.agents.types())
# one type by its slug
# print(client.agents.type_by_slug("<slug>"))
You get back: the available types.
This is also where a parent agent's tool-loop limit comes from. An orchestrator agent is capped at 10 delegation rounds unless you link a type with a higher maxIterations.
Add an agent template to the Hub
What this does: registers an agent so people can spawn it. Two flavours: a Docker image, or an external URL. This is also where you set the protocol (always mcp), the plans it may run on, and the ports it listens on — the SDK sends all three exactly like the Web UI does.
API POST /api/v1/agents
# a container-based agent
entry = client.agents.create(
"my-agent", # name (1-50 characters)
"Answers questions about our docs", # description
"server", # type: "client" or "server"
"open source", # licence: "open source" or "third party"
image_tags=[{"displayName": "my-agent:1.0",
"dockerImage": "myorg/my-agent:1.0"}],
visibility="private",
# PROTOCOL — "mcp" is the default, so you can leave this out
protocol="mcp",
# PLANS the agent is allowed to run on (plan _id values, not names).
# default_plan is the one pre-selected at spawn time, and it must be
# one of the plans above. Naming only a default_plan attaches it too.
plans=[PLAN_ID],
default_plan=PLAN_ID,
# PORTS — the ports your agent listens on, set here at import time
ports=[{"protocol": "HTTP", "number": 8080,
"portIdentifier": "mcp"}], # portIdentifier is optional
# envs=[{"key": "LOG_LEVEL", "value": "debug"}],
# args=[{"key": "--verbose", "value": "true"}],
# volume_path="/data",
)
# change ports or plans later — each REPLACES the whole list,
# so pass everything you want to keep
client.agents.update(
entry["_id"],
ports=[{"protocol": "HTTP", "number": 8080, "portIdentifier": "mcp"},
{"protocol": "HTTP", "number": 9090, "portIdentifier": "metrics"}],
plans=[PLAN_A, PLAN_B],
default_plan=PLAN_A,
)
# or an agent that already runs somewhere else
client.agents.create(
"external-agent", "Hosted elsewhere", "client", "open source",
external_url="https://mcp.example.com",
external_transport="streamable-http", # or "sse"
)
You get back: the created template. Save _id — you need it to update or delete it.
Import an agent that already runs somewhere else
What this does: registers an external MCP server as a template. There is no image and no ports — the template just points at a URL.
API POST /api/v1/agents
external = client.agents.create(
"deepwiki", # name
"Answers questions about public GitHub repos",
"server", # the Hub imports MCP SERVERS
"open source",
external_url="https://mcp.deepwiki.com/mcp",
external_transport="streamable-http", # or "sse"
protocol="mcp",
plans=[PLAN_ID], default_plan=PLAN_ID,
visibility="private",
)
print("imported:", external["_id"], "->", external.get("externalUrl"))
# then attach it as a tool when you spawn an agent:
# AGENT_SERVERS = [external["_id"]]
You get back: the created template, including the URL and transport it will use.
Use streamable-http, not sse. The older SSE endpoints of most public MCP servers have been retired and answer 404 or 410.
An external template is a third-party dependency. If their service goes down, so does your agent's tool. Fine for trying things out; think twice before relying on one.
Change a template
What this does: edits an entry in the Hub. Send only what you want changed.
API PUT /api/v1/agents/:id
client.agents.update(AGENT_TEMPLATE_ID, description="now answers questions about our docs")
client.agents.update(AGENT_TEMPLATE_ID, visibility="organization")
You get back: the updated template.
A partial update used to fail, and no longer does.
The platform's validator re-checks that a template has a source (an image, a URL, or a linked service) but only reads what you send — so changing just the description was rejected. The SDK now re-sends the existing source for you, and your update stays partial.
Change its ports or its plans
What this does: updates the runtime settings on the template — the ports the agent listens on, and which plans it may run on.
API PUT /api/v1/agents/:id
client.agents.update(
AGENT_TEMPLATE_ID,
ports=[{"protocol": "HTTP", "number": 8080, "portIdentifier": "mcp"},
{"protocol": "HTTP", "number": 9090, "portIdentifier": "metrics"}],
plans=[PLAN_A, PLAN_B],
default_plan=PLAN_A,
)
You get back: the updated template, with the new ports under parameters.
default_plan must be one of plans. Naming a default alone attaches it.
Each list replaces the old one. Passing ports= or plans= overwrites what was there — include everything you want to keep, not just the additions.
Register several templates at once
What this does: adds more than one Hub entry in a single pass.
API POST /api/v1/agents (once per template)
from zeblok.batch import unique_names, batch
names = unique_names("my-agent-tpl", 2, existing=client.agents.get_all)
result = batch(names, lambda n: client.agents.create(
n, "registered by a script", "server", "open source",
image_tags=[{"displayName": "busybox:latest", "dockerImage": "busybox:latest"}],
ports=[{"protocol": "HTTP", "number": 8080, "portIdentifier": "mcp"}],
protocol="mcp", plans=[PLAN_ID], default_plan=PLAN_ID, visibility="private"))
print(result.summary())
You get back: a result object listing what was created.
Remove a template
What this does: deletes the catalog entry.
API DELETE /api/v1/agents/:id
client.agents.delete(AGENT_TEMPLATE_ID)
still = any((a.get("_id") or a.get("id")) == AGENT_TEMPLATE_ID
for a in (client.agents.get_all() or []))
print("still in the Hub?", still) # False
You get back: confirmation, then False proving it is gone.
Agents already spawned from this template keep running. Removing the template only stops anyone spawning new ones from it.
Imports › Datasets
Storing files on the platform — and getting them back.
Steps: 1. List → 2. Create → 3. Upload files → 4. Download → 5. Delete
A dataset is a named folder. Files go in as versions: every upload creates a new version, so the old files stay where they were.
There is no rename. The platform lets you create, read and delete a dataset — not update one. Name and description are fixed once created. To change a name, make a new dataset and delete the old one.
| What you want to do | Code | API |
|---|---|---|
| See all datasets | client.datasets.get_all() |
GET /api/v1/datasets |
| Find one by name | client.datasets.get_by_name(name) |
GET /api/v1/datasets |
| See one by id | client.datasets.get_by_id(id) |
GET /api/v1/datasets/:id |
| Does it still exist? | client.datasets.validate_id(id) |
GET /api/v1/datasets/:id |
| Make one | client.datasets.create_dataset(...) |
POST /api/v1/datasets |
| Put files in | client.datasets.upload_dataset(id, paths) |
POST /dataset-versions |
| See its versions | client.datasets.versions(id) |
GET /dataset-versions/:datasetId |
| See its files | client.datasets.files(name) |
GET /datasets/files/:name |
| Files in one version | client.datasets.version_files(v, name) |
GET /dataset-versions/files/:v |
| Download a file | client.datasets.download_file(name, dest) |
GET /dataset-versions/download |
| Delete it | client.datasets.delete_dataset(id) |
DELETE /api/v1/datasets/:id |
List your datasets
What this does: shows the datasets that exist, and finds one by name.
API GET /api/v1/datasets
from zeblok.utils.errors import NoResourcesError
try:
datasets = client.datasets.get_all(print_stdout=False)
except NoResourcesError:
datasets = []
print("no datasets exist here yet")
print(f"{len(datasets)} datasets")
if datasets:
print(client.datasets.get_by_name(datasets[0]["name"]))
You get back: the dataset list, and one looked up by name.
An empty list raises rather than returning []. When no datasets exist, this call raises NoResourcesError — the SDK's signal for "none", not an error you need to fix. Catch it, as above, in any script that must run on a fresh environment.
Create a dataset and put a file in it
What this does: makes the dataset, then uploads one or more files as its first version.
API POST /api/v1/datasets · POST /api/v1/dataset-versions
ds_id = client.datasets.create_dataset(
dataset_name="customer-churn",
dataset_description="training data for the churn model",
datacenter_id=DATACENTER_ID,
)
client.datasets.upload_dataset(ds_id, filepaths=["/home/me/churn.csv"])
You get back: the new dataset id, then a confirmation of what uploaded.
Paths are on the machine running your code — the workstation if you are in a Zeblok notebook, your laptop if you are local. A file:///… path works too. A web address does not: download the file first.
Look inside, and get a file back
What this does: lists what is stored, then downloads one file to your machine.
API GET /dataset-versions/:datasetId · /datasets/files/:name · /dataset-versions/download
name = client.datasets.get_by_id(ds_id)["name"]
print(client.datasets.versions(ds_id)) # every version
print(client.datasets.files(name)) # every file
# save one file to a folder, keeping its name
path = client.datasets.download_file("churn.csv", "/home/me/downloads")
print("saved to", path)
You get back: the version list, the file list, then the path the file was written to.
Delete a dataset
What this does: removes the dataset and everything in it.
API DELETE /api/v1/datasets/:id
client.datasets.delete_dataset(ds_id)
print("gone?", not client.datasets.validate_id(ds_id))
You get back: confirmation, then True once it is gone.
Check which object store is behind the platform
What this does: tells you whether files are stored in MinIO, AWS or Azure. Worth knowing when an upload behaves oddly, because the three respond differently.
API GET /api/v1/datasets/objectStorage/type
print("object store:", client.datasets.object_storage_type()) # MINIO | AWS | AZURE
print("bucket for this dataset:", client.datasets.bucket_name(DS_ID))
You get back: the storage type, and the bucket a dataset lives in.
The SDK already adapts its upload to whichever store is in use — this call is for your own diagnosis, not something you have to act on.
Configurations › Data Center
A datacenter is the physical place where machines live. Read-only in the SDK.
Steps: 1. List → 2. Look at machines → 3. Check free capacity
You cannot create or change datacenters from the SDK — they are managed by the platform team. You can look at them, and check how much capacity is free.
| What you want to do | Code | API |
|---|---|---|
| See all datacenters | client.datacenters.get_all() |
GET /api/v1/datacenters |
| See one datacenter | client.datacenters.get_by_id(id) |
GET /api/v1/datacenters/:id |
| See public datacenters | client.datacenters.get_all_public() |
GET /api/v1/datacenters/public |
| See its machines | client.datacenters.nodes(id) |
GET /api/v1/datacenters/:id/nodes |
| See free capacity | client.datacenters.metrics(id) |
GET /api/v1/datacenters/:id/metrics |
| Image registry address | client.datacenters.registry_url() |
GET /api/v1/datacenters/registry-url |
| Resources across all DCs | client.datacenters.resources() |
GET /api/v1/datacenters/resources |
| Platform environment info | client.datacenters.microcloud_environment() |
GET /api/v1/datacenters/microCloudEnvironment |
List datacenters and look at their machines
What this does: shows the datacenters available to you, then the machines inside one of them and how much is free.
API GET /api/v1/datacenters · /:id/nodes · /:id/metrics
dcs = client.datacenters.get_all(print_stdout=False)
DC_ID = dcs[0]["id"]
print(dcs) # [{'id': …, 'name': 'Phison labs', 'category': 'pro'}]
nodes = client.datacenters.nodes(DC_ID)
print(f"{len(nodes)} machines in this datacenter")
metrics = client.datacenters.metrics(DC_ID)
print("Free capacity:", metrics.get("maxAvailable"))
You get back: a list of datacenters; a list of machines; and a summary of free capacity.
Platform-wide reads
What this does: four small calls that describe the platform as a whole rather than one datacenter: which datacenters are shared across organisations, where built images are pushed, how much capacity exists in total, and what flavour of environment you are on.
API GET /api/v1/datacenters/public · /registry-url · /resources · /microCloudEnvironment
print(client.datacenters.get_all_public()) # shared across organisations
print(client.datacenters.registry_url()) # where built images are pushed
print(client.datacenters.resources()) # capacity across all datacenters
print(client.datacenters.microcloud_environment()) # which environment flavour this is
You get back: the public datacenter list, the container-registry address, aggregate capacity, and the environment description.
registry_url() is the one you are most likely to need. It is the registry your own images must be pushed to before a template can pull them.
Any of these can come back empty or refused depending on your role. That is a normal answer, not a failure.
Configurations › Plans
A plan is a size: how much CPU, GPU, memory and storage a workload gets.
Steps: 1. List plans → 2. Check capacity → 3. Create → 4. Update → 5. Delete
Every workload you start — a workstation, a microservice, an AI model — must be given a plan. The plan decides how big it is. In the Web UI these live under Plans & Resources.
name: Small-CPU
resources: 1 CPU, 0 GPU, 2 GB memory, 10 GB storage
price: 0 USD
| # | What you want to do | Code | API |
|---|---|---|---|
| 1 | See all plans | client.plans.get_all() |
GET /api/v1/plans |
| 2 | See one plan | client.plans.get_by_id(id) |
GET /api/v1/plans/:id |
| 3 | Check a plan exists | client.plans.validate_id(id) |
GET /api/v1/plans/:id |
| 4 | Get only some fields | client.plans.get_filtered_details(id, fields) |
GET /api/v1/plans/:id |
| 5 | See public plans | client.plans.get_all_public() |
GET /api/v1/plans/public |
| 6 | Check free capacity | client.plans.capacity(dc_id) |
GET /api/v1/plans/datacenter/:id/capacity |
| 7 | Create a plan | client.plans.create(...) |
POST /api/v1/plans |
| 8 | Create a custom-size plan | client.plans.create_dynamic(...) |
POST /api/v1/plans/dynamic |
| 9 | Change a plan | client.plans.update(id, ...) |
PUT /api/v1/plans/:id |
| 10 | Change only the storage | client.plans.update_storage(id, gb) |
PUT /api/v1/plans/storage/:id |
| 11 | Delete a plan | client.plans.delete(id) |
DELETE /api/v1/plans/:id |
1. See all plans
What this does: gets the list of every plan you can use. Run this first — you need a plan id for almost everything else.
API GET /api/v1/plans
plans = client.plans.get_all(print_stdout=False)
print(f"You have {len(plans)} plans")
for p in plans:
r = p["resources"]
print(p["id"], "|", p["name"], "|", r.get("CPU"), "CPU,", r.get("memory"), "GB")
You get back: a list. Each item looks like:
{"id": "6a5dca…", "name": "Small-CPU", "price": 0, "currency": "USD",
"resources": {"CPU": 1, "GPU": 0, "memory": 2, "storage": 10},
"is_public": False, "data_center": {"id": "…", "name": "Phison labs"}}
Use print_stdout=True (the default) and the SDK prints a readable list for you.
2. See one plan
What this does: gets the full details of a single plan, using its id.
API GET /api/v1/plans/:id
PLAN_ID = plans[0]["id"] # take an id from the list above
plan = client.plans.get_by_id(PLAN_ID, print_stdout=False)
print(plan["name"], plan["resources"])
You get back: one plan, same shape as above. If the id is wrong you get a 'not found' error.
3. Check a plan exists
What this does: answers only yes or no. It never stops your program with an error, so it is the safe one to use inside scripts.
API GET /api/v1/plans/:id
if client.plans.validate_id(PLAN_ID):
print("Plan exists — safe to use")
else:
print("This plan does not exist")
You get back: True or False.
4. Get only some fields
What this does: returns just the fields you ask for, instead of the whole plan.
API GET /api/v1/plans/:id
small = client.plans.get_filtered_details(PLAN_ID, fields_req=["id", "type"])
print(small) # {'id': '6a5dca…', 'type': ''}
You get back: a small dictionary with only the fields you asked for.
5. See public plans
What this does: shows plans shared across organisations, not only your own.
API GET /api/v1/plans/public
public_plans = client.plans.get_all_public(print_stdout=False)
print(f"{len(public_plans)} public plans")
You get back: a list of plans, same shape as get_all().
6. Check free capacity before creating
What this does: tells you how much CPU, GPU and memory is still free in a datacenter, and the biggest plan that still fits on one machine.
API GET /api/v1/plans/datacenter/:id/capacity
DC_ID = client.datacenters.get_all(print_stdout=False)[0]["id"]
cap = client.plans.capacity(DC_ID)
print("Biggest plan that fits on one machine:", cap["max"])
for node in cap["nodes"]:
print(f" {node['name']}: {node['CPU']} CPU free, {node['memory']} GB free")
You get back: free capacity per machine, plus the maximum:
{"nodes": [{"name": "worker-1", "CPU": 4, "GPU": 1, "memory": 82}, …],
"max": {"CPU": 4, "GPU": 1, "memory": 82}}
Why this matters: you can create a plan bigger than any machine. The platform will accept it, but no workload will ever start on it. If max says 4 CPU, a plan asking for 8 CPU will never run.
7. Create a plan
What this does: makes a new plan with a name and a size you choose. It appears in the Web UI immediately.
API POST /api/v1/plans
new_plan = client.plans.create(
plan_name="my-small-plan", # required — any name (max 200 characters)
datacenter_id=DC_ID, # required — where the plan can run
resources={ # required
"GPU": 0, # number of GPUs
"CPU": 1, # number of CPU cores
"memory": 2, # memory in GB
"storage": 10, # disk in GB (optional)
},
price=0, # optional
currency="USD", # optional — "USD" or "INR" only
visibility="private", # optional — "private", "organization" or "public"
)
PLAN_ID = new_plan["_id"]
print("Created plan:", PLAN_ID, new_plan["planName"])
You get back: the created plan. Save _id — you need it to update or delete the plan later.
Rules the SDK checks before sending:
| Rule | Why |
|---|---|
resources must contain GPU, CPU and memory |
The platform needs all three. Use 0 if you don't want GPUs. |
CPU or memory must be more than 0 |
A plan with nothing in it cannot run anything. |
currency must be USD or INR |
Only these two are accepted. |
visibility must be private, organization or public |
Anything else is rejected. |
With is_auto_scaling=True you must pass node_group_name |
Auto-scaling needs to know which machine group to grow. |
7b. Read back what you created
What this does: fetches the plan you just made and checks the stored record matches what you sent. Worth doing once after a create, because a field the platform quietly ignored shows up here.
API GET /api/v1/plans/:id
print(client.plans.get_by_id(PLAN_ID, print_stdout=False))
print("valid?", client.plans.validate_id(PLAN_ID))
You get back: the stored plan — name, resources, visibility and datacenter, as the platform recorded them.
8. Create a custom-size (dynamic) plan
What this does: creates a plan when you only care about the size, not the name. The platform makes the name for you.
API POST /api/v1/plans/dynamic
dyn = client.plans.create_dynamic(
datacenter_id=DC_ID,
resources={"GPU": 0, "CPU": 1, "memory": 2, "storage": 5},
)
print("Auto-generated name:", dyn["planName"])
print("Plan id:", dyn["_id"])
You get back: the created plan, with a name the platform chose.
Use it when you want to start a workload at a size no existing plan matches. Unlike create(), you do not pass a name, price, currency or visibility.
9. Change a plan
What this does: changes one or more details of an existing plan. Send only what you want to change — everything else stays the same.
API PUT /api/v1/plans/:id
updated = client.plans.update(
PLAN_ID,
plan_name="my-renamed-plan", # change the name
price=1, # change the price
)
print("New name:", updated["planName"], "| New price:", updated["price"])
You get back: the updated plan.
You can change: plan_name, price, currency, resources, datacenter_id, visibility, allowed_notebooks, is_auto_scaling, node_group_name.
If you pass nothing to change, the SDK stops you with pass at least one field to update.
10. Change only the storage
What this does: changes just the disk size of a plan. Nothing else is touched.
API PUT /api/v1/plans/storage/:id
out = client.plans.update_storage(PLAN_ID, 25) # 25 GB
print("Storage is now:", out["resources"]["storage"], "GB")
You get back: the updated plan.
Why a separate call? The platform has a dedicated API just for storage — the Web UI uses it too.
11. Delete a plan
What this does: removes a plan from the platform. This cannot be undone.
API DELETE /api/v1/plans/:id
print("Deleted:", client.plans.delete(PLAN_ID)) # True means success
print("Still exists?", client.plans.validate_id(PLAN_ID)) # False
You get back: True when the plan is deleted.
Before deleting, make sure no running workload is using this plan.
Full example — the whole life of a plan
from zeblok import ZeblokClient
client = ZeblokClient("https://backend.<env>.zeblok.com", "<key>", "<secret>")
# 1. Pick a datacenter
DC_ID = client.datacenters.get_all(print_stdout=False)[0]["id"]
# 2. Check what still fits there
print("Biggest plan that fits:", client.plans.capacity(DC_ID)["max"])
# 3. Create a small plan
plan = client.plans.create(
plan_name="demo-plan", datacenter_id=DC_ID,
resources={"GPU": 0, "CPU": 1, "memory": 2, "storage": 10},
price=0, currency="USD", visibility="private",
)
plan_id = plan["_id"]
# 4. Read it back
print(client.plans.get_by_id(plan_id, print_stdout=False))
# 5. Rename it and give it more disk
client.plans.update(plan_id, plan_name="demo-plan-v2")
client.plans.update_storage(plan_id, 25)
# 6. Delete it and confirm
client.plans.delete(plan_id)
print("Gone?", not client.plans.validate_id(plan_id))
12. Confirm it is really gone
What this does: checks the plan no longer exists after a delete. This is what turns "the call did not error" into "the plan is actually gone".
API GET /api/v1/plans/:id
print("still exists?", client.plans.validate_id(PLAN_ID)) # expect False
PLAN_ID = None
You get back: False — proof the whole create → update → delete round-trip left nothing behind.
Use validate_id(), not get_by_id(). After a delete, get_by_id() raises; validate_id() just answers False, which is what you want in a script.
13. Create several plans at once
What this does: makes a batch of plans with names that do not clash with anything already on the platform, and keeps going if one of them fails.
API POST /api/v1/plans (once per plan)
from zeblok.batch import unique_names, batch, delete_many
names = unique_names("my-plan", 3, existing=lambda: client.plans.get_all(print_stdout=False))
result = batch(names, lambda n: client.plans.create(
n, DC_ID, {"GPU": 0, "CPU": 1, "memory": 2, "storage": 5},
price=0, currency="USD", visibility="private"))
print(result.summary()) # "3 succeeded, 0 failed"
print(result.ids) # ids of everything created
# remove the whole batch again
delete_many(result, lambda i: client.plans.delete(i))
You get back: a result object with .ok, .failed, .ids and .summary().
A plain for loop breaks here in two ways: the platform rejects a name that already exists, so a second run fails on the first item; and one bad item stops the loop half-way. See Creating many at once.
Configurations › Namespaces
A namespace is a folder where your workloads run, with its own members.
Steps: 1. List → 2. Create → 3. Add members → 4. Delete
Every workload you start must go into a namespace. Namespaces also control who can see and use what is inside them. In the Web UI: Configurations → Namespaces.
| What you want to do | Code | API |
|---|---|---|
| See all namespaces | client.namespaces.get_all() |
GET /api/v1/namespaces |
| See one namespace | client.namespaces.get_by_id(id) |
GET /api/v1/namespaces/:id |
| Check it exists | client.namespaces.validate_id(id) |
GET /api/v1/namespaces/:id |
| List by organisation | client.namespaces.by_organisation(org_id) |
GET /api/v1/namespaces/organisation/:orgId |
| Create | client.namespaces.create(name, ...) |
POST /api/v1/namespaces |
| Rename / change members | client.namespaces.update(id, ...) |
PUT /api/v1/namespaces/:id |
| Delete | client.namespaces.delete(id) |
DELETE /api/v1/namespaces/:id |
| Delete by k8s name | client.namespaces.delete_by_k8s_name(name) |
DELETE /api/v1/namespaces/k8s/:name |
See all namespaces
What this does: lists the namespaces you can deploy into. You need one id to start any workload.
API GET /api/v1/namespaces
namespaces = client.namespaces.get_all(print_stdout=False)
NAMESPACE_ID = namespaces[0]["id"]
print(namespaces)
You get back: a list of {'id': …, 'name': …}.
Check a namespace really resolves
What this does: confirms a namespace can be fetched by id before you try to deploy into it. On some environments the list includes namespaces that the by-id lookup cannot return.
API GET /api/v1/namespaces/:id
NAMESPACE_ID = None
for ns in client.namespaces.get_all(print_stdout=False):
if client.namespaces.validate_id(ns["id"]):
NAMESPACE_ID = ns["id"]
print("using namespace:", ns)
break
print("skipping (by-id lookup failed):", ns)
assert NAMESPACE_ID, "no namespace could be resolved by id"
You get back: the first namespace that both lists and resolves — the one safe to spawn into.
Why bother?
The list can include public namespaces belonging to other organisations, and asking for one of those by id can fail. Picking the first entry blindly therefore works most days and fails confusingly on others. This loop is the reliable way to choose one.
validate_id() answers True/False instead of raising, which is what makes the loop possible.
List by organisation, and delete by Kubernetes name
What this does: two extra ways in: all the namespaces belonging to an organisation, and deleting one by the name Kubernetes knows it by rather than its id.
API GET /api/v1/namespaces/organisation/:orgId · DELETE /api/v1/namespaces/k8s/:name
ORG_ID = client.organisations.me()["_id"]
print(client.namespaces.by_organisation(ORG_ID))
# the full stored record, including its members
print(client.namespaces.raw(NS_ID))
# delete by the k8s name instead of the id
client.namespaces.delete_by_k8s_name("my-team-space")
You get back: the organisation's namespaces, the full record, and confirmation of the delete.
raw() is the untrimmed record — use it when you need fields get_by_id() leaves out, such as the member list.
Create several namespaces at once
What this does: sets up namespaces for a group of teams in one pass, with names that do not clash.
API POST /api/v1/namespaces (once per namespace)
from zeblok.batch import unique_names, batch, delete_many
names = unique_names("team", 3, existing=client.namespaces.get_all)
result = batch(names, lambda n: client.namespaces.create(n, is_public=False))
print(result.summary())
# undo the whole batch
# delete_many(result, lambda i: client.namespaces.delete(i))
You get back: a result object listing what was created.
Create a namespace
What this does: makes a new namespace. It appears in the Web UI immediately.
API POST /api/v1/namespaces
created = client.namespaces.create(
"my-team-space", # required — the name
is_public=False, # optional — can others see it?
users=[], # optional — member user ids
)
NS_ID = created["_id"]
You get back: the created namespace. Save _id.
Rename a namespace
What this does: changes the name. Other details stay the same.
API PUT /api/v1/namespaces/:id
client.namespaces.update(NS_ID, name="my-team-space-v2")
You get back: the updated namespace.
Add and remove members safely
What this does: changes who belongs to a namespace without wiping the people already in it. Use these rather than update(users=…) — see the warning below.
API PUT /api/v1/namespaces/:id
print("members now:", client.namespaces.members(NS_ID))
# ADD — keeps whoever is already there
users = client.users.list()
client.namespaces.add_users(NS_ID, users[0]["_id"])
# several at once
client.namespaces.add_users(NS_ID, [users[0]["_id"], users[1]["_id"]])
# REMOVE one, keeping the rest
client.namespaces.remove_users(NS_ID, users[0]["_id"])
print("members now:", client.namespaces.members(NS_ID))
You get back: the updated namespace. members() shows the list at any time.
Two rules the platform enforces.
Each entry must be a bare 24-character _id from client.users.list() — not a name, an email or a user object. And every member must be in the same organisation as the namespace, or the call is refused with CROSS_ORG_USERS.
To clear everyone deliberately: client.namespaces.update(NS_ID, users=[]). That is the one time replacing the whole list is what you want.
This is the trap worth knowing about.
The platform's update call replaces the member list — it does not add to it. So attaching people one at a time with update(users=[id]) silently removes everyone attached before: run it for person A, then person B, and only B is left. add_users() reads the current list, merges yours in, and sends the whole thing — so it accumulates the way you expect.
The trimmed view hides members, which is why a successful attach can look like it did nothing. Use members() or raw() to see them.
Delete a namespace
What this does: removes the namespace. Make sure nothing is running inside it first.
API DELETE /api/v1/namespaces/:id
client.namespaces.delete(NS_ID)
print("Gone?", not client.namespaces.validate_id(NS_ID))
You get back: True when deleted.
Configurations › Buckets
The platform's register of storage buckets that workloads can mount.
Steps: 1. List → 2. Register → 3. Remove
A bucket here is an entry in the platform's register — it is what a workstation attaches when you mount storage into it. This is a separate thing from your personal object-store bucket (the one used by pipelines), and from datasets.
| What you want to do | Code | API |
|---|---|---|
| See registered buckets | client.buckets.list() |
GET /api/v1/buckets |
| Register one | client.buckets.create(...) |
POST /api/v1/buckets |
| Remove one | client.buckets.delete(id) |
DELETE /api/v1/buckets/:id |
List, register and remove a bucket
What this does: shows what is registered, adds a new entry, then removes it.
API GET · POST · DELETE /api/v1/buckets
print(client.buckets.list()) # often empty on a fresh platform — that is fine
b = client.buckets.create(
name="team-data", # required
storage_type="MINIO", # required — "MINIO", "AWS" or "AZURE"
description="shared team files", # optional
is_public=False, # optional
)
print("registered:", b["_id"])
client.buckets.delete(b["_id"])
You get back: the list, then the created record (save _id), then a confirmation.
An empty list is normal. Many environments register no buckets at all. Your personal object-store bucket exists in the storage system but does not appear here — the two registers are separate.
Using a bucket: mount it when you start a workstation, with bucket_name=, bucketmount_path= and isbucket_attached=True. See Workstations.
IAM › Users
Adding people to the platform, and deciding what they may do.
Steps: 1. List people → 2. Invite or create → 3. Set role / quotas → 4. Deactivate
Everything here needs an admin or superadmin key. An ordinary key gets "not authorized".
The words, in plain terms
The Web UI's IAM menu shows five pages. The SDK covers those, plus two things the menu hides. Those two are not extra features — they are what the Roles and Policies pages are built on.
| Word | What it really is | Example | Where in the Web UI |
|---|---|---|---|
| User | One person. | a colleague's email | IAM → Users |
| User group | A bag of users, so you grant access once instead of person by person. | Developers, Viewers | IAM → User Groups |
| Role | A named job that carries permissions. | Administrator | IAM → Roles |
| Resource | The name of a protected area of the platform. It is not a workstation or a model — it is the label the platform checks before it lets a request through. | iam.users, which guards /api/v1/users |
no page — hidden |
| Permission | One grant: this role may do these actions on that resource. | Developers may read iam.users |
behind IAM → Policies |
| Policy | Allow and deny rules, with priority when two disagree. | deny deleting after hours | IAM → Policies |
| Organisation | The tenant everything belongs to. | your company | IAM → Organisations |
Why is there no Resources page?
Because you rarely need one. The list only changes when a new area of the platform is built and needs to become grantable. Day to day you pick roles and groups, and the resource names sit underneath.
Reading or editing that list needs the superadmin role. An admin key gets "not authorized" — that is intended, not a fault.
Built-in roles cannot be changed.
Roles that came with the platform — anything seeded, or named admin, developer, standard-user, viewer — are locked. You cannot edit them, delete them, or grant permissions on them, and no role lets you: a superadmin is refused too. The error reads "System roles are protected and their permissions cannot be edited."
To hand out access, make your own role and grant against that:
role = client.iam.roles.create("data-team")
client.iam.permissions.create(role=role["_id"], resource="plans", actions=["read"])
The role must also belong to your own organisation.
Some IAM actions need superadmin, not just admin.
These return "not authorized" on an admin key, by design:
client.iam.resources — all of it
client.iam.permissions.delete() and .clear_cache()
client.iam.policies.create() / update() / delete()
Everything else — roles, groups, granting and updating permissions, listing policies and the policy dry-run — works on an admin key. So you can test a policy before you are allowed to create one.
| What you want to do | Code | API |
|---|---|---|
| See everyone | client.users.list() |
GET /api/v1/users |
| See one person | client.users.get(id) |
GET /api/v1/users/:id |
| Search | client.users.filter(...) |
GET /api/v1/users/filter |
| Why can they do that? | client.users.effective_permissions(id) |
GET /users/:id/effective-permissions |
| Invite someone | client.users.invite(email) |
POST /api/v1/users/invite |
| Create an account directly | client.users.create(...) |
POST /api/v1/users |
| Change role or quotas | client.users.update(id, ...) |
PUT /api/v1/users/:id |
| Switch an account off | client.users.deactivate(id) |
PUT /api/v1/users/:id |
| Remove someone | client.users.delete(id) |
DELETE /api/v1/users/:id |
List people, and find out what one of them can do
What this does: reads the people on the platform, and resolves everything a single person is allowed to do once their roles and group memberships are combined.
API GET /api/v1/users · /users/:id · /users/:id/effective-permissions
users = client.users.list()
for u in users[:5]:
print(u.get("_id"), "|", u.get("email"), "|", u.get("username"))
USER_ID = users[0]["_id"]
print(client.users.get(USER_ID))
# the answer to "why can this person do that?"
print(client.users.effective_permissions(USER_ID))
print(client.organisations.list())
You get back: the people list, one full record, and their combined permissions.
effective_permissions() is the one to reach for when someone can do something they should not, or cannot do something they should. It combines roles and groups, which is usually where the surprise is hiding.
Search, and list by organisation
What this does: narrows the list down instead of reading everyone.
API GET /api/v1/users/filter · /users/org/:orgId · /users/agent-users
print(client.users.filter(search="priya")) # same search the Web UI box does
ORG_ID = client.organisations.me()["_id"]
print(client.users.by_organisation(ORG_ID))
print(client.users.agent_users()) # service accounts used by agents
# look someone up directly
print(client.users.find(email="priya@yourcompany.com"))
You get back: the matching people.
find() is the convenient one when you already know the email or username and just need the id.
Invite a colleague
What this does: emails them an invitation. They choose their own name, username and password from the link, so you never handle anyone's password. This is the normal way to add a person.
API POST /api/v1/users/invite
client.users.invite("colleague@yourcompany.com")
You get back: confirmation that the invitation was sent.
Create an account yourself
What this does: makes the account immediately, with a password you choose. Use it for automation, seeding or tests — otherwise prefer an invitation.
API POST /api/v1/users
person = client.users.create(
"Priya Sharma", # name
"priya@yourcompany.com", # email
"priya.sharma", # username
"Str0ng!pass", # password
iam_role_id=ROLE_ID, # their role
usergroups=[GROUP_ID], # optional
)
# later: change what they can do
client.users.update(person["_id"], allowed_notebooks=5)
# they left the team — switch the account off, keep their work
client.users.deactivate(person["_id"])
# or remove them for good
client.users.delete(person["_id"])
You get back: the new user record. Save _id.
Password rules. At least 8 characters, with one uppercase, one lowercase, one number and one special character. The SDK checks this before sending, so you get a clear message instead of a rejected request.
Deactivate before you delete. Turning an account off is reversible and keeps their work intact. Deleting a primary admin is refused unless you name a replacement with reassign_to=<other admin id>, because someone has to inherit their resources.
Ready to hand out access? IAM › Roles is the next section, with a step-by-step recipe.
Change what someone can use
What this does: updates a person's role, quotas or group membership.
API PUT /api/v1/users/:id
client.users.update(USER_ID, allowed_notebooks=5, allowed_services=2)
client.users.update(USER_ID, iam_role_id=ROLE_ID) # change their role
client.users.update(USER_ID, usergroups=[GROUP_ID]) # change their groups
client.users.update(USER_ID, namespaces=[NAMESPACE_ID]) # namespaces they may use
You get back: the updated record.
Fields you can set, all snake_case: name, email, username, is_active, iam_role_id, role_ids, user_role, usergroups, namespaces, allowed_notebooks, allowed_services, allowed_k8s_addons, default_bucket, phone. Anything else is refused with the allowed list.
Switch an account off instead of deleting it
What this does: deactivates a person, which is reversible and keeps their work. This is normally what you want when someone leaves.
API PUT /api/v1/users/:id
client.users.deactivate(USER_ID) # they can no longer sign in
client.users.activate(USER_ID) # ...and back again
You get back: the updated record.
Deactivate first, delete later. Turning the account off stops access immediately and gives you time to work out what should happen to anything they own.
Add several people at once
What this does: creates accounts from a list of addresses, skipping anyone already on the platform so a re-run tops up rather than failing.
API POST /api/v1/users (once per person)
from zeblok.batch import unique_usernames, batch, delete_many
PEOPLE = [
{"email": "amir@yourcompany.com", "name": "Amir Haddad"},
{"email": "priya@yourcompany.com", "name": "Priya Sharma"},
]
SHARED_PASSWORD = "Str0ng!pass" # 8+ chars, upper, lower, digit, symbol
existing = client.users.list() or []
taken = {str(u.get("username", "")).lower() for u in existing}
taken |= {str(u.get("email", "")).lower() for u in existing}
rows = {}
for person in PEOPLE:
email = person["email"].strip()
if email.lower() in taken:
print("skip", email, "— already on the platform")
continue
username = unique_usernames(email.split("@")[0], 1, existing=list(taken))[0]
taken |= {username.lower(), email.lower()}
rows[email] = {"name": person["name"], "username": username}
result = batch(list(rows), lambda e: client.users.create(
rows[e]["name"], e, rows[e]["username"], SHARED_PASSWORD))
print(result.summary())
You get back: a result object naming who was created and who failed.
Usernames come from the email's local part. The platform allows letters, digits, _ and . only — so unique_usernames() is used here rather than unique_names(), which would produce dashes and be rejected.
Prefer invitations for real colleagues. This sets a password you both know; an invitation lets them choose their own.
Remove someone
What this does: deletes an account for good, with options for the awkward cases.
API DELETE /api/v1/users/:id
client.users.delete(USER_ID)
still = any(u.get("_id") == USER_ID for u in (client.users.list() or []))
print("still listed?", still) # False
# the harder cases:
# reassign_to="<other-admin-id>" # REQUIRED when deleting a primary admin
# force=True # push past soft blocks
# cascade_delete_org=True # only if they are the last person in the org
# shared_resource_actions={res_id: "public"} # what happens to what they shared
You get back: confirmation, then False proving they are gone.
Deleting a primary admin needs a replacement. Someone has to inherit their resources, so the call is refused unless you name one with reassign_to=.
IAM › Roles
A role is a named job that carries permissions. This is where you create one and grant it.
Steps: 1. Check permissions → 2. Create a role → 3. Grant it → 4. Attach people
IAM › Users explains the words — user, group, role, resource, permission, policy. This section is how you actually work with them: roles, the permissions you grant on them, and the audit trail of who changed what.
Most of this needs an admin key, and a few parts need superadmin. Getting 403 · not authorized here usually means your role is not high enough — not that anything is broken. Check yours first:
print(client.iam.my_permissions().raw.get("role"))
What each role can do
| Area | Reading | Creating / changing | Deleting |
|---|---|---|---|
| Roles | any key with access | admin | admin |
| User groups | any key with access | admin | admin |
| Permissions (grants) | admin | admin | superadmin |
| Policies | admin (including the dry-run) | superadmin | superadmin |
| Resources (the registry) | superadmin | superadmin | superadmin |
| Audit trail | admin | — | — |
Yes, that means you can test a policy before you are allowed to create one. That is deliberate.
Checking permissions
What can I do? What can they do?
What this does: answers "is this person allowed to do that?" — for yourself, or for anyone else. This is the answer to why can this user do that?, because it combines their roles and their group memberships.
API GET /api/v1/auth/me/permissions · GET /users/:id/effective-permissions
# me
me = client.iam.my_permissions()
print(me.allows("plans", "list"))
print(me.raw) # a property — no brackets
# someone else
user_id = client.users.list()[0]["_id"]
them = client.iam.effective_permissions(user_id)
print(them.allows("iam.users", "read"))
# a whole group
group_id = client.iam.groups.list()[0]["_id"]
print(client.iam.groups.effective_permissions(group_id).allows("ai.models", "read"))
You get back: a permission set. allows(resource, action) gives True/False; .raw gives everything behind it.
Roles
| What you want to do | Code | API |
|---|---|---|
| See all roles | client.iam.roles.list() |
GET /api/v1/roles |
| See one role | client.iam.roles.get(id) |
GET /api/v1/roles/:id |
| Which roles a person has | client.iam.roles.by_user(user_id) |
GET /api/v1/roles/user/:id |
| Who has this role | client.iam.roles.members(id) |
GET /api/v1/roles/:id/members |
| What would change if I edit it | client.iam.roles.impact(id) |
GET /api/v1/roles/:id/impact |
| Create / change / delete | create() · update() · delete() |
POST/PUT/DELETE /api/v1/roles |
Make your own role
What this does: creates a role you can attach permissions to. You need this because the built-in roles are locked.
API POST /api/v1/roles
role = client.iam.roles.create(
name="data-team",
description="read-only access for the data team",
)
ROLE_ID = role["_id"]
client.iam.roles.update(ROLE_ID, description="updated description")
print(client.iam.roles.members(ROLE_ID)) # who currently holds it
print(client.iam.roles.impact(ROLE_ID)) # what a change would affect
client.iam.roles.delete(ROLE_ID)
You get back: the created role. Save _id.
Built-in roles cannot be edited by anyone
— not even superadmin. A role is built-in if it came with the platform (isSeeded) or is named admin, developer, standard-user or viewer. The error reads "System roles are protected and their permissions cannot be edited." Create your own role instead.
To spot the editable ones:
editable = [r for r in client.iam.roles.list()
if not r.get("isSeeded")
and str(r.get("role", "")).lower() not in
("admin", "developer", "standard-user", "viewer")]
Permissions (grants)
A permission is one sentence: this role may do these actions on that resource.
Grant access to a role
What this does: gives a role the right to do something. This is what actually opens a door.
API GET/POST/PUT/DELETE /api/v1/authorization/permissions
# which resources can I grant against?
print(client.iam.permissions.available_resources())
grant = client.iam.permissions.create(
role=ROLE_ID, # must be YOUR OWN custom role
resource="plans",
actions=["read"],
)
GRANT_ID = grant["_id"]
client.iam.permissions.update(GRANT_ID, actions=["read", "update"])
# read them back
print(client.iam.permissions.by_role(ROLE_ID))
print(client.iam.permissions.by_resource("plans"))
# several at once
client.iam.permissions.bulk_create([
{"role": ROLE_ID, "resource": "namespaces", "actions": ["read"]},
{"role": ROLE_ID, "resource": "datasets", "actions": ["read", "download"]},
])
# superadmin only:
client.iam.permissions.delete(GRANT_ID)
client.iam.permissions.clear_cache()
You get back: the grant. Save _id if you want to change or remove it later.
Valid actions: create, read, update, delete, start, stop, share, upload, download.
Two rules the platform enforces. The role must be one of your own custom roles (built-in roles are locked for everyone), and it must belong to your own organisation.
Audit trail
Who changed what
What this does: shows the governance record — the actions taken, and headline counts of users, roles, groups and permissions.
API GET /api/v1/iam/audit · GET /api/v1/iam/stats
print(client.iam.audit.stats()) # counts
for entry in client.iam.audit.list()[:10]:
print(entry)
You get back: summary counts, and a list of recorded actions.
Putting it together
Recipe: give the data team read access to plans
What this does: the whole flow, start to finish — make a role, grant it, group people under it, add someone, and check it worked.
# 1 — a role of your own (built-in roles cannot be granted on)
role = client.iam.roles.create("data-team", description="read-only for the data team")
ROLE_ID = role["_id"]
# 2 — what that role may do
client.iam.permissions.create(role=ROLE_ID, resource="plans", actions=["read"])
client.iam.permissions.create(role=ROLE_ID, resource="namespaces", actions=["read"])
# 3 — a group carrying the role
grp = client.iam.groups.create(name="data-team-group", roles=[ROLE_ID])
GROUP_ID = grp["_id"]
# 4 — put a person in it
user = client.users.find(email="priya@yourcompany.com")
client.users.update(user["_id"], usergroups=[GROUP_ID])
# 5 — prove it
print(client.users.effective_permissions(user["_id"]))
print(client.iam.effective_permissions(user["_id"]).allows("plans", "read")) # True
You get back: a role, a grant, a group, an updated user — and a True confirming it all connected.
IAM › Policies
Allow and deny rules, with a priority when two disagree.
Steps: 1. List policies → 2. Dry-run one → 3. Create (superadmin)
A permission says what a role may do. A policy handles the cases a plain grant cannot express — "deny deletes outside working hours", for example — and decides which rule wins when two conflict.
Read policies and test one before it bites
What this does: lists policies, then dry-runs one against an example request. The dry-run changes nothing — it just tells you what would happen.
API GET /authorization/policies · POST /authorization/policies/:id/test
policies = client.iam.policies.list()
print(client.iam.policies.templates()) # ready-made starting points
print(client.iam.policies.priority()) # the order rules are evaluated in
POL_ID = policies[0]["_id"]
verdict = client.iam.policies.test(
POL_ID,
user={"role": "developer", "organisationId": ORG_ID}, # attributes, not a name
resource={"type": "plans", "visibility": "private"}, # attributes, not a name
action="read",
)
for rule in verdict["ruleResults"]:
print(rule["ruleName"], "->", rule["finalEffect"])
print("decision:", verdict["summary"]["finalDecision"])
You get back: one verdict per rule — whether the action matched, whether the conditions were met, and the final effect — plus an overall decision.
user and resource must be dictionaries of attributes
, not names or ids. The policy's conditions are checked against those attributes, so put in whatever your rules look at. Passing a plain string is rejected — the SDK catches that before sending and tells you.
Creating, changing and deleting policies needs superadmin: client.iam.policies.create(...), .update(...), .delete(...).
Resources (the hidden registry)
The list of protectable areas
What this does: reads (and, rarely, edits) the register of names that permissions are granted against — plans, iam.users and so on. It is not a list of your workstations or models; it is the list of labels the platform checks.
API GET/POST/PUT/DELETE /api/v1/resources
print(client.iam.resources.list())
# only when a NEW area of the platform needs to become grantable:
res = client.iam.resources.create("sdk.reports", "/api/v1/reports")
client.iam.resources.update(res["_id"], route="/api/v1/reports-v2")
client.iam.resources.delete(res["_id"])
You get back: the registry entries.
Every call here needs superadmin
, including reading. An admin key gets 403 Insufficient role privileges — that is the design, not a fault. There is no Web UI page for this either, because the list only changes when the platform itself gains a new area.
Day to day you do not need this. To see what you may grant against, use client.iam.permissions.available_resources() — that one works on an admin key.
IAM › Organisations
The tenant everything belongs to — your company on the platform.
Steps: 1. Find yours → 2. Create → 3. Update
Every user, plan, namespace and workload belongs to exactly one organisation. Most people only ever have one and never touch this page. It matters in two places: when you add someone (they join yours), and when a call is refused because a role or a user belongs to a different organisation.
| What you want to do | Code | API | Role needed |
|---|---|---|---|
| See mine | client.organisations.me() |
GET /api/v1/organisations/me |
any |
| See all I can see | client.organisations.list() |
GET /api/v1/organisations |
any |
| See one | client.organisations.get(id) |
GET /api/v1/organisations/:id |
any |
| Create one | client.organisations.create(...) |
POST /api/v1/organisations |
superadmin |
| Change one | client.organisations.update(id, ...) |
PUT /api/v1/organisations/:id |
admin |
| Delete one | client.organisations.delete(id) |
DELETE /api/v1/organisations/:id |
superadmin |
Find out which organisation you are in
What this does: tells you your own tenant. This is the answer when something is refused "because it belongs to another organisation".
API GET /api/v1/organisations/me · GET /api/v1/organisations
mine = client.organisations.me()
print("I am in:", mine["name"], "|", mine["_id"])
ORG_ID = mine["_id"]
for org in client.organisations.list():
print(org.get("_id"), "|", org.get("name"))
print(client.organisations.get(ORG_ID))
You get back: your organisation record, the list you can see, and one full record.
Why this matters.
Two rules bite regularly, and both are about organisations: namespace members must all be in the same organisation as the namespace, and you can only grant permissions on a role in your own organisation. If either fails, compare the ids here.
Create and change an organisation
What this does: sets up a new tenant, or edits the details of one. Creating and deleting need superadmin.
API POST · PUT · DELETE /api/v1/organisations
admin_user = client.users.find(email="owner@newcompany.com")
org = client.organisations.create(
"New Company Ltd", # name — 2 to 200 characters
admin_user["_id"], # the user who will own it (an id, not an email)
country="India", # everything below is optional
company_type="private",
business_contact_name="Priya Sharma",
business_contact_title="CTO",
billing_contact_name="Accounts",
no_of_employees=250,
is_public=False,
)
NEW_ORG_ID = org["_id"]
client.organisations.update(NEW_ORG_ID, no_of_employees=300, country="India")
client.organisations.delete(NEW_ORG_ID)
You get back: the created organisation (save _id), the updated record, then a confirmation.
The admin value is a user id, not an email address. Look it up first with client.users.find(email=…) — the SDK rejects anything that is not a 24-character id, so you get a clear message instead of a rejected request.
Optional fields, all snake_case: country, company_type, business_contact_name, business_contact_title, billing_contact_name, billing_contact_title, sales_turnover, no_of_employees, no_of_branch, is_public. Anything else is refused with the allowed list.
Deleting an organisation is drastic — everything inside it goes. In practice you deactivate the people in it instead.
IAM › User Groups
A bag of users, so you grant access once instead of person by person.
Steps: 1. List groups → 2. Create with roles → 3. Add people
A group carries one or more roles. Put someone in the group and they inherit everything those roles hold — which is easier to keep straight than granting per person.
Group people so you grant once
What this does: creates a group carrying one or more roles. Put people in the group and they inherit everything it holds.
API GET/POST/PUT/DELETE /api/v1/usergroups
print(client.iam.groups.list())
grp = client.iam.groups.create(
name="data-team-group",
roles=[ROLE_ID], # role ids this group carries
)
GROUP_ID = grp["_id"]
client.iam.groups.update(GROUP_ID, name="data-team-group-v2")
# what the group can do, all roles combined
print(client.iam.groups.effective_permissions(GROUP_ID).raw)
client.iam.groups.delete(GROUP_ID)
# put someone in it
client.users.update(USER_ID, usergroups=[GROUP_ID])
You get back: the created group. Save _id.
API Keys & Secrets
Two kinds of keys: one to use the platform, one to talk to AI models.
Steps: 1. Check your key → 2. Rotate it → 3. Create a Gen-AI key → 4. Revoke
| What you want to do | Code | API |
|---|---|---|
| Check your platform key | client.keys.credentials_status() |
GET /api/v1/users/credentials/status |
| Make a new platform key | client.keys.generate() |
POST /api/v1/users/generatekeys |
| List your Gen-AI keys | client.ai_keys.list() |
GET /api/v1/users/ai-keys |
| See Gen-AI usage | client.ai_keys.usage() |
GET /api/v1/users/ai-keys/usage |
| Create a Gen-AI key | client.ai_keys.create(...) |
POST /api/v1/users/ai-keys |
| Revoke a Gen-AI key | client.ai_keys.revoke(id) |
DELETE /api/v1/users/ai-keys/:id |
Careful with client.keys.generate(). It creates a new platform key and immediately cancels the old one. Any script still using the old key will stop working. Update your code with the new values right away.
Check the platform key you are using
What this does: asks the platform about the key pair your client is sending. The quickest way to tell whether you pasted an old key.
API GET /api/v1/users/credentials/status
print(client.keys.credentials_status())
# {'hasCredentials': True, 'keyPreview': '777d…0414', ...}
You get back: whether a pair exists, and a short preview to compare against what you pasted.
Replace your platform key
What this does: mints a new key and secret. The old pair stops working the moment this returns.
API POST /api/v1/users/generatekeys
new_pair = client.keys.generate()
print(new_pair["apiKey"]) # save both NOW
print(new_pair["apiSecret"]) # the secret is never shown again
# the client you are holding still has the DEAD pair — rebuild it
client = ZeblokClient(APP_URL, new_pair["apiKey"], new_pair["apiSecret"])
You get back: the new key and secret.
This cuts off everything using the old pair — your own script mid-run, colleagues, notebooks, scheduled jobs. The very next call on the old client fails with 401 User not authenticated. Rotate deliberately, and tell anyone else who shares the key.
List your Gen-AI keys and what they have spent
What this does: shows the chat keys on your account and their token usage.
API GET /api/v1/users/ai-keys · /ai-keys/usage
print(client.ai_keys.list()) # your keys (previews only, never the tokens)
print(client.ai_keys.usage()) # tokens spent per key
You get back: the key list and the usage figures.
The full token is never listed. It is shown once, when the key is created. What you see here is the zbl_… preview — enough to tell keys apart, not enough to use one.
usage() is how you find out which key is driving your token spend.
Create a Gen-AI key (for chatting with models)
What this does: creates a key you can use to talk to deployed models and agents.
API POST /api/v1/users/ai-keys
key = client.ai_keys.create(name="my-chat-key", key_type="user", expiry_days=30)
print("COPY THIS NOW — shown only once:", key["aiKey"])
KEY_ID = key["credential"]["_id"]
You get back: the key. Two parts matter:
aiKey— the long token you actually use. Shown once.credential._id— the id you need to revoke it later.
Revoke a Gen-AI key
What this does: cancels a key. Anything using it stops working immediately.
API DELETE /api/v1/users/ai-keys/:id
client.ai_keys.revoke(KEY_ID) # use credential["_id"], not the zbl_… preview
You get back: confirmation that the key is revoked.
For what each credential is actually for, and how to store them safely, see Authentication explained.
Containerization
When the platform builds an image for you, this is where you watch it.
Steps: 1. List builds → 2. Read the log
Some operations do not just deploy something — they build a container image first. Importing an AI model builds its serving image; creating a pipeline or AI-API builds an image from your code folder. Each of those creates a build job. In the Web UI these appear under Containerization.
| What you want to do | Code | API |
|---|---|---|
| See build jobs | client.builds.list() |
GET /api/v1/caas |
| Filter by state | client.builds.list(state="success") |
GET /api/v1/caas?state=… |
| See one build | client.builds.get(id) |
GET /api/v1/caas/:id |
| Read its log | client.builds.logs(id) |
GET /api/v1/caas/:id/logs |
| Remove a build record | client.builds.delete(id) |
DELETE /api/v1/caas/:id |
Find out why a build failed
What this does: lists build jobs, then reads the log of one. This is the answer to "my model import failed and I do not know why".
API GET /api/v1/caas · /caas/:id · /caas/:id/logs
builds = client.builds.list()
for b in builds[:5]:
print(b.get("_id"), "|", b.get("state"), "|", b.get("name"))
if builds:
BUILD_ID = builds[0]["_id"]
print(client.builds.get(BUILD_ID)) # the full record
print(client.builds.logs(BUILD_ID)) # the build output — read this on a failure
# only the failed ones
print(client.builds.list(state="failed"))
You get back: a list of build jobs, the details of one, and its build log as text.
An empty list is normal if nothing has been built on this environment yet. Build jobs appear once you import a model or create a pipeline or AI-API.
LifeCycle Manager
The controls every running workload shares — status, stop, start, restart, resize, delete.
Steps: 1. Get a handle → 2. Check status → 3. Stop / start → 4. Resize → 5. Delete
The Web UI calls this the LifeCycle Manager. In the SDK it is one handle that works the same way whatever you started.
Whatever you start, you manage it the same way: get a handle, then call status(), stop(), restart(), delete() and so on. This section is the reference for all of them, so the component sections do not have to repeat it.
Getting a handle
handle = client.spawned("<type>", "<id>")
# agents are slightly different — they have their own handle
sa = client.spawned_agent("<agent-id>")
| You started a… | Type to pass | How to find its id |
|---|---|---|
| Workstation | "image" |
client.workstations.spawned() |
| Microservice | "service" |
client.microservices.spawned() |
| AI model | "inference" |
client.inferences.get_all_spawned_inferences() |
| Orchestration add-on | "addon" |
client.orchestrations.spawned() |
| Agent | use client.spawned_agent(id) |
client.agents.spawned() |
The value spawn() returns is not the id you manage with.
Starting something gives you back the pod name (something like zbl-ms-abc123). Every management call needs the database id instead — a 24-character value. Passing the pod name gives you 400 Validation failed, which is confusing because nothing is really wrong.
Convert it:
svc_id = client.microservices.get_spawned_id_by_name(pod_name)
Or just take _id from the list of running things.
Looking at it
Status, logs, usage, replicas
What this does: the read-only checks. Safe to run any time.
API GET /spawned-…/:id · /:id/logs · /:id/utilization · /replica-status/:id
running = client.microservices.spawned()
handle = client.spawned("service", running[0]["_id"])
st = handle.status()
print("state:", st.get("status"), "| name:", st.get("name"))
print(handle.logs()) # what the container printed
print(handle.utilization("5m")) # CPU / memory / GPU over the last 5 minutes
print(handle.replica_status()) # how many copies are up (microservices only)
You get back: the full record, the log text, usage numbers, and replica counts.
On a stopped workload, utilization() returns empty values and logs() can answer "not found". That is normal — start it first.
replica_status() exists only for microservices. On any other type the SDK tells you so instead of sending a request that would fail.
Get the link to open it
What this does: gives you the same URL as the Open button in the Web UI, plus the in-cluster address other workloads should use.
API (built from the status record — no extra call)
print("open in a browser:", handle.open_url())
print("address for other workloads:", handle.internal_url())
for e in handle.endpoints():
print(f" {e.get('label')}: {e.get('url')} [{e.get('scope')}]")
You get back: the public link, the internal link, and a list of every endpoint the workload exposes.
For workstations the link also carries the login token, so it opens JupyterLab already signed in.
open_url() returns None while the workload is still starting, when it is stopped, or when it is internal-only. That is an answer, not an error.
Wait until it is ready
What this does: pauses your script until the workload reports running, instead of you polling in a loop.
handle.wait_until_ready(timeout=600) # up to 10 minutes
print("now:", handle.status().get("status"))
You get back: control, once it is ready — or a timeout error if it never gets there.
Large AI models can take 20 minutes or more to pull and load. Use a bigger timeout for those: wait_until_ready(timeout=1800).
Changing it
Stop, start, restart
What this does: the everyday controls. Stopping keeps everything and frees the resources; starting brings it back.
API PUT /spawned-…/:id/stop | start | restart
handle.stop() # frees CPU/GPU, keeps the workload and its settings
handle.start() # bring it back
handle.restart() # recreate it in one step, keeping id, plan and URL
You get back: the platform's response for each.
Restart is not the same as stop+start. It is one call, and it keeps the same id, plan, configuration and URL. Use it after an edit() so the new settings take effect.
Agents cannot restart. The platform has no restart endpoint for them. Stop and start instead — the SDK tells you this clearly rather than sending a request that fails.
Edit vs reconfigure — which one do I want?
What this does: both change a running workload. Edit changes what is inside it (replicas, environment variables, image, storage mounts). Reconfigure changes what it runs on (plan, size, worker count).
API PUT /spawned-…/edit/:id · PUT /spawned-…/reconfigure/:id
# EDIT — what is inside
handle.edit(updated_replicas=2) # microservice: run 2 copies
handle.edit(configuration={"envs": [{"key": "LOG_LEVEL", "value": "debug"}]})
handle.edit(docker_image="myorg/app:2.0") # swap the image
handle.edit(resource_details={"CPU": 2, "memory": 4, "GPU": 0})
# RECONFIGURE — what it runs on
handle.reconfigure(plan_id=BIGGER_PLAN_ID) # move to another plan
handle.reconfigure(name="renamed-service")
handle.restart() # apply the change
You get back: the updated record.
Reconfigure takes different fields for each type. Passing the wrong one fails immediately, with the allowed list in the message:
| Type | Fields it accepts |
|---|---|
Microservice (service) |
plan_id, resource_details, parameters, updated_replicas, name |
Workstation (image) |
plan_id, resource_details, parameters, name |
AI model (inference) |
min_replicas, max_replicas, threshold, args, ports, envs |
Add-on (addon) |
min_workers, max_workers, head_plan_id, worker_plan_id — no plan_id |
| Agent | system_prompt, servers, context_length, available_sub_agents, … |
Add-ons have no edit endpoint at all — resize them with reconfigure().
Share it with someone
What this does: gives other people access to this workload. You can pass emails, usernames or ids — the SDK looks up anything that is not already an id.
API PUT /spawned-…/share/:id
# see who you can share with
for u in client.users.list():
print(u.get("email"), "|", u.get("username"))
handle.share(["colleague@yourcompany.com", "priya.sharma"])
You get back: the updated record. Its allowedUsers now contains the people you named.
If a name does not match anyone, the error lists every email you can see, so you can spot the right spelling.
Delete it
What this does: removes the workload permanently.
API DELETE /spawned-…/:id
handle.delete(stop_first=True) # stop, wait for it to settle, then delete
# confirm
remaining = [s["_id"] for s in (client.microservices.spawned() or [])]
print("still there?", running[0]["_id"] in remaining) # False
You get back: confirmation once it is gone.
The platform refuses to delete something that is running. Always use stop_first=True and the SDK handles the stop-wait-delete sequence for you.
Creating many at once
Safe names and error handling when you create more than one thing.
Creating several things in a normal loop breaks in two ways. First, the platform refuses a name that is already used, so running your script a second time fails on the very first item. Second, one failure stops the whole loop, leaving you half-finished with no record of what worked.
The zeblok.batch helpers solve both, and work with every component — plans, namespaces, templates, workstations, microservices, add-ons and agents.
Make names that cannot clash
What this does: generates names that avoid everything already on the platform, and each other.
API (no API call — uses the list you pass in)
from zeblok.batch import unique_names, unique_name, safe_name
# three free names, checked against the plans that already exist
names = unique_names("my-plan", 3, existing=lambda: client.plans.get_all(print_stdout=False))
print(names) # -> my-plan, my-plan-2, my-plan-3
unique_name("my-plan", existing=client.plans.get_all) # one free name
unique_name("my-plan", random_suffix=True) # -> my-plan-k3x9
safe_name("My Test Service!") # -> my-test-service
You get back: a list of names you can use straight away.
existing accepts a list of names, a list of records, or a lister function such as client.plans.get_all. If the lister raises because nothing exists yet, that is treated as empty.
Workload names become Kubernetes names, so they must be lowercase with dashes only, and most are capped at 50 characters. safe_name() and the helpers handle both rules.
Create many, and keep going when one fails
What this does: runs your create call for every item, records what worked and what did not, and never stops half-way.
API whichever endpoint your function calls
from zeblok.batch import batch, delete_many
result = batch(names, lambda n: client.plans.create(
n, DATACENTER_ID, {"GPU": 0, "CPU": 1, "memory": 2, "storage": 5},
price=0, currency="USD", visibility="private",
))
print(result.summary()) # "3 succeeded, 0 failed" (+ the reason for any failure)
print(result.ids) # ids of everything that was created
result.ok # [(item, result), …]
result.failed # [(item, exception), …]
# clean up everything that was created
delete_many(result, lambda i: client.plans.delete(i))
You get back: a result object listing successes, failures and the created ids.
All-or-nothing runs: pass stop_on_error=True to halt at the first failure, and cleanup=lambda r: client.plans.delete(r['_id']) to undo what was already created.
Strict mode: result.raise_if_any_failed() raises one error listing every failure.
The same pattern works everywhere. Updating many is identical — batch(ids, lambda i: client.plans.update(i, price=1)):
# spawn three workstations
names = unique_names("my-ws", 3, existing=client.workstations.spawned)
batch(names, lambda n: client.workstations.spawn(
display_name=tag, workstation_id=WS_ID, plan_id=PLAN_ID,
workstation_name=n, namespace_id=NAMESPACE_ID))
# create three namespaces
batch(unique_names("team", 3, existing=client.namespaces.get_all),
lambda n: client.namespaces.create(n))
# spawn two agents
batch(unique_names("agent", 2, existing=client.agents.spawned),
lambda n: client.agents.spawn(n, namespace_id=NAMESPACE_ID,
datacenter_id=DATACENTER_ID))
Live status updates
Get told when a workload changes state, instead of asking over and over.
Normally you wait with handle.wait_until_ready(), which checks the status every few seconds. If you would rather be pushed updates as they happen, the platform can stream them — but that stream needs a different credential.
This needs a browser session token (JWT), not your API key pair.
The streaming layer does not accept API keys. To get one: open the Web UI, open your browser's developer tools, look at Network, click any request to the platform, and copy the value of the Authorization header. It expires after a while, so this is best for interactive work rather than scheduled jobs.
Wait for a workload to be ready, by push
What this does: subscribes to one workload's status events and returns as soon as it reaches the state you asked for.
API Socket.IO status channel
import os
from zeblok.realtime import StatusStream
JWT = os.environ["ZBL_JWT"] # browser session token, NOT the API key
stream = StatusStream(APP_URL, JWT, "inference", SPAWNED_INFERENCE_ID)
print("reached:", stream.wait_for(ready_states=("running",), timeout=120))
You get back: the state it reached, or a timeout if it never got there.
You usually do not need this. client.spawned(type, id).wait_until_ready(timeout=…) does the same job with your normal key pair, and quietly uses the push channel itself when a JWT and the optional realtime extra are available.
The resource type is the same word as everywhere else: image, service, inference, addon, agent.
Check everything works
One script that touches every read-only part of the platform and prints a health matrix.
Run this after installing the SDK, after upgrading it, or when something feels wrong and you want to know how much is affected. It only reads — nothing is created, changed or deleted.
from zeblok import ZeblokClient
from zeblok.utils.errors import NoResourcesError, AuthorizationError
client = ZeblokClient(APP_URL, ACCESS_KEY, ACCESS_SECRET)
DC_ID = client.datacenters.get_all(print_stdout=False)[0]["id"]
checks = {
"health": lambda: client.health(),
"config": lambda: client.config(),
"my key": lambda: client.keys.credentials_status(),
"plans": lambda: client.plans.get_all(print_stdout=False),
"plan capacity": lambda: client.plans.capacity(DC_ID),
"namespaces": lambda: client.namespaces.get_all(print_stdout=False),
"datacenters": lambda: client.datacenters.get_all(print_stdout=False),
"datacenter nodes": lambda: client.datacenters.nodes(DC_ID),
"workstation catalog": lambda: client.workstations.get_all(print_stdout=False),
"workstations running": lambda: client.workstations.spawned(),
"microservice catalog": lambda: client.microservices.get_all(print_stdout=False),
"microservices running": lambda: client.microservices.spawned(),
"add-on catalog": lambda: client.orchestrations.get_all(print_stdout=False),
"add-ons running": lambda: client.orchestrations.spawned(),
"model catalog": lambda: client.inferences.get_all(print_stdout=False),
"models running": lambda: client.inferences.get_all_spawned_inferences(print_stdout=False),
"agent templates": lambda: client.agents.get_all(),
"agents running": lambda: client.agents.spawned(),
"datasets": lambda: client.datasets.get_all(print_stdout=False),
"buckets": lambda: client.buckets.list(),
"builds": lambda: client.builds.list(),
"users": lambda: client.users.list(),
"organisations": lambda: client.organisations.list(),
"my permissions": lambda: client.iam.my_permissions().raw,
"roles": lambda: client.iam.roles.list(),
"groups": lambda: client.iam.groups.list(),
"policies": lambda: client.iam.policies.list(),
"gen-ai keys": lambda: client.ai_keys.list(),
}
width = max(len(n) for n in checks)
for name, call in checks.items():
try:
out = call()
count = len(out) if isinstance(out, (list, dict)) else ""
print(f"OK {name:<{width}} {count}")
except NoResourcesError:
print(f"EMPTY {name:<{width}} nothing of this type exists yet")
except AuthorizationError:
print(f"NO-PERM {name:<{width}} your role may not read this")
except Exception as e:
print(f"FAILED {name:<{width}} {type(e).__name__}: {e}")
How to read the output
| Line | Meaning | Do something? |
|---|---|---|
OK |
Works, and here is how many items came back. | No. |
EMPTY |
The call works — there is simply nothing of that type on this environment yet. | No. Create one if you need it. |
NO-PERM |
Your credentials are fine; your role is not allowed to read this. | Only if you expected access. Ask an administrator. |
FAILED |
Something genuinely went wrong. | Yes — this is the one worth reporting. |
If everything fails, start at the top: client.health() failing means the address or the network is the problem, and nothing else can work until that is fixed.
Errors and what they mean
The SDK tries to fail with a clear message. Here are the ones you are most likely to see.
| Message you see | What it means | What to do |
|---|---|---|
401 · User not authenticated |
Your API key or secret is wrong, expired, or was replaced. | Get fresh keys from the Web UI (API Keys & Secrets → Microcloud). |
403 · User not authorized |
Your account's role is not allowed to do this. | Ask an administrator to give your role permission. |
NoResourcesError: No … available |
Nothing of that type exists yet. This is not a bug. | Create one first, or check you are on the right environment. |
404 / not found |
The id does not exist (or was deleted). | List the items first and copy a real id. |
400 · Validation failed |
The platform rejected the data you sent. | Check the required fields for that call in this guide. |
Please stop the running … first |
You tried to delete something that is running. | Use handle.delete(stop_first=True). |
plan must reserve at least some CPU or memory |
Your plan asked for 0 CPU and 0 memory. | Give at least one of them a value. |
users must be a list of 24-character user ObjectIds |
You passed names or placeholders as members. | Use _id values from client.users.list(). |
ImportError: cannot import name 'ZeblokClient' |
An old version of the SDK is installed. | Reinstall, then restart your Python kernel. |
Good habits
- Read before you write. List things and check capacity before creating anything.
- Save the id. Create calls return
_id. Without it you cannot update or delete later. - Use the
validate_id()checks in scripts — they return True/False instead of stopping your program. - Clean up test items. If you create something to try it out, delete it afterwards.
- Restart the kernel after upgrading the SDK. Python keeps the old version in memory until you do.
- Test on a test environment first. Create, update and delete are real changes.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zeblok_sdk-2.0.0.tar.gz.
File metadata
- Download URL: zeblok_sdk-2.0.0.tar.gz
- Upload date:
- Size: 305.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c6c43b73434d95e7aef6bc2f79267865273043c10dbe516a0f2d1d7995f39123
|
|
| MD5 |
5daf3e8f45b5b5fbe0a98d1ba7c19572
|
|
| BLAKE2b-256 |
b80eb0bfb7f7741e828faea1c548d0714a6f0e978878be9dd03827041ec22506
|
File details
Details for the file zeblok_sdk-2.0.0-py3-none-any.whl.
File metadata
- Download URL: zeblok_sdk-2.0.0-py3-none-any.whl
- Upload date:
- Size: 170.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
11758babb05b47204394811d78b4ca660442f7ab2cdc37e53b933e2a9a6e8c43
|
|
| MD5 |
5bee3172313acc5db71d0f66b4950555
|
|
| BLAKE2b-256 |
8f0acebb4eb477c070d2572f57c251a27dd85dc0d287b4f2675289ddf31e8f9a
|