Skip to main content

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 InstallGet your keysConnectYour 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

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)

  1. Web UI → Platform → API Keys & SecretsMicrocloud tab.
  2. Click Generate (or Regenerate Keys if you already have a pair).
  3. 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 / thresholdnot 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_idno 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

zeblok_sdk-2.0.0.tar.gz (305.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

zeblok_sdk-2.0.0-py3-none-any.whl (170.4 kB view details)

Uploaded Python 3

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

Hashes for zeblok_sdk-2.0.0.tar.gz
Algorithm Hash digest
SHA256 c6c43b73434d95e7aef6bc2f79267865273043c10dbe516a0f2d1d7995f39123
MD5 5daf3e8f45b5b5fbe0a98d1ba7c19572
BLAKE2b-256 b80eb0bfb7f7741e828faea1c548d0714a6f0e978878be9dd03827041ec22506

See more details on using hashes here.

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

Hashes for zeblok_sdk-2.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 11758babb05b47204394811d78b4ca660442f7ab2cdc37e53b933e2a9a6e8c43
MD5 5bee3172313acc5db71d0f66b4950555
BLAKE2b-256 8f0acebb4eb477c070d2572f57c251a27dd85dc0d287b4f2675289ddf31e8f9a

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

2.0.0 This release

2 files

1.4.0

2 files

1.3.0

2 files

1.2.1

2 files

1.2.0

2 files

1.1.2

2 files

1.1.1

2 files

1.1.0

2 files

1.0.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page