Metadata-Version: 2.4
Name: zeblok-sdk
Version: 2.0.0
Summary: Zeblok Python SDK
Author-email: Prashant Saini <prashant.saini@zeblok.com>
Maintainer-email: Zeblok <zeblok@zeblok.com>, Prashant Saini <prashant.saini@zeblok.com>
License: This work is created by DataTurtles LLP (party 1) for Zeblok Computation Inc (party 2). It is illegal to use this work without permission from either parties.
Project-URL: Homepage, https://github.com/zeblok/python-sdk
Keywords: zeblok,python-sdk,zeblok-sdk
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE.md
Requires-Dist: requests
Requires-Dist: minio
Requires-Dist: tqdm
Requires-Dist: pydantic>=2
Provides-Extra: s3
Requires-Dist: boto3; extra == "s3"
Provides-Extra: azure
Requires-Dist: azure-storage-blob; extra == "azure"
Provides-Extra: realtime
Requires-Dist: python-socketio[client]; extra == "realtime"
Provides-Extra: all
Requires-Dist: boto3; extra == "all"
Requires-Dist: azure-storage-blob; extra == "all"
Requires-Dist: python-socketio[client]; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: responses; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Dynamic: license-file

# Zeblok Python SDK — User Guide

Control the Ai-MicroCloud platform from Python. This guide explains every operation: what it does, which API it calls, the code to copy, and what you get back.

New here? Start with [Install](#install) → [Get your keys](#keys) → [Connect](#connect) → [Your first script](#quickstart), then jump to the component you need. [Authentication explained](#auth) covers every credential in one place.

**This guide follows the Web UI menu.**

The sections below are named and grouped exactly like the sidebar you already use — **Workspace** for things that are running, **Platform → Imports** for the templates they start from, **Configurations** and **IAM** for setup. If you can find it in the app, you can find it here.

**Two menu entries are not in this guide.**

**Gen AI Workspace → Knowledge Distillations** and **Domain Adaptations** have no SDK support yet — there is no `client.…` for them, so use the Web UI for those. **Home**, **Support** and **About** are pages, not operations. Everything else in the sidebar is covered below.

## Table of Contents

- **Getting started**
  - [What this guide is](#about)
  - [Step 1 — Install the SDK](#install)
  - [Step 2 — Get your keys](#keys)
  - [Step 3 — Connect](#connect)
  - [Authentication explained](#auth)
  - [Your first script](#quickstart)
- **Workspace (things that are running)**
  - [Workspace › Workstations](#workstations)
  - [Workspace › Microservices](#microservices)
  - [Workspace › Orchestration Add-on](#orchestration)
  - [Workspace › Ai-API & Pipelines](#pipelines)
  - [Workspace › Inference](#inference)
  - [Workspace › Agents](#agents)
- **Platform › Imports (the templates they start from)**
  - [Imports › Workstations](#ws-import)
  - [Imports › Microservices](#ms-import)
  - [Imports › Orchestration Add-ons](#oa-import)
  - [Imports › Model Hub](#model-hub)
  - [Imports › Agent Hub](#agent-hub)
  - [Imports › Datasets](#datasets)
- **Configurations**
  - [Configurations › Data Center](#datacenters)
  - [Configurations › Plans](#plans)
  - [Configurations › Namespaces](#namespaces)
  - [Configurations › Buckets](#buckets)
- **IAM (people and access)**
  - [IAM › Users](#people)
  - [IAM › Roles](#roles)
  - [IAM › Policies](#policies)
  - [IAM › Organisations](#organisations)
  - [IAM › User Groups](#user-groups)
- **Platform utilities**
  - [API Keys & Secrets](#api-keys)
  - [Containerization](#builds)
  - [LifeCycle Manager](#manage)
  - [Creating many at once](#bulk)
  - [Live status updates](#realtime)
- **Reference**
  - [Check everything works](#health-check)
  - [Errors and what they mean](#errors)
  - [Good habits](#good-habits)

<a id="about"></a>

## 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.

<a id="install"></a>

## Step 1 — Install the SDK

You need Python 3.9 or newer.

```bash
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.

<a id="keys"></a>

## 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 & Secrets** → **Microcloud** 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:

```bash
# 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…"
```

```python
# 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.

<a id="connect"></a>

## 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)

```python
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`

```python
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. |

<a id="auth"></a>

## 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](#realtime).

### 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.

```python
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`

```python
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`

```python
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`

```python
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`

```python
# 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`

```python
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.

```python
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`

```python
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](#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. |

<a id="quickstart"></a>

## 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.

```python
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](#workstations) |
| Deploy an app or API | [Microservices](#microservices) |
| Serve an AI model and chat with it | [Inference](#inference) |
| Build an AI assistant with tools | [Agents](#agents) |
| Upload files | [Datasets](#datasets) |
| Add colleagues and control access | [Users & access](#people) |
| Stop, resize or delete something already running | [Managing what you started](#manage) |

**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(...)`

<a id="workstations"></a>

## 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](#ws-import).

| 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|start` |
| 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`

```python
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`

```python
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`

```python
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](#manage).

### 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)`

```python
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](#bulk).

**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`

```python
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`

```python
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)`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
# 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`

```python
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](#datasets) or a mounted bucket first.

<a id="microservices"></a>

## 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](#ms-import).

| 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|start|restart` |
| 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`

```python
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`

```python
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](#manage).

### 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)`

```python
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`

```python
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`

```python
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)`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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.

<a id="orchestration"></a>

## 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](#oa-import).

| 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|start|restart` |
| 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`

```python
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](#manage).

### 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)`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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.

<a id="pipelines"></a>

## 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.

```python
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](#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`

```python
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](#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.

```python
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`

```python
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.

<a id="inference"></a>

## 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|start|restart` |
| 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`

```python
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`

```python
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)`

```python
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`

```python
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`

```python
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`

```python
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)`

```python
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`

```python
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`

```python
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`

```python
handle.reconfigure(min_replicas=1, max_replicas=2, threshold=80)
```

**You get back:** the updated record.

**Models take different fields from every other workload.** `min_replicas` / `max_replicas` / `threshold` — **not** `updated_replicas`, and not `plan_id`. Passing the wrong one is refused straight away with the list of what is allowed.

### See who you can share with, and share

**What this does:** lists the people visible to you, then gives one of them access to the served model.

API `GET /api/v1/users · PUT /spawned-inferences/:id/share`

```python
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`

```python
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`

```python
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)`

```python
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](#manage).

<a id="agents"></a>

## 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](#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|start` |
| 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`

```python
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`

```python
# 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`

```python
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`

```python
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)`

```python
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]`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
# 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](#manage).

<a id="ws-import"></a>

## 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](#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`

```python
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`

```python
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`

```python
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)`

```python
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.

<a id="ms-import"></a>

## 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](#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`

```python
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`

```python
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`

```python
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)`

```python
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.

<a id="oa-import"></a>

## 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](#orchestration).

| 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`

```python
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`

```python
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`

```python
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`

```python
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)`

```python
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.

<a id="model-hub"></a>

## 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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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](#builds).

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`

```python
# 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.

<a id="agent-hub"></a>

## 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](#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`

```python
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`

```python
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`

```python
# 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`

```python
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`

```python
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`

```python
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)`

```python
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`

```python
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.

<a id="datasets"></a>

## 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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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.

<a id="datacenters"></a>

## 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`

```python
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`

```python
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.

<a id="plans"></a>

## 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**.

```text
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`

```python
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:

```python
{"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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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:

```python
{"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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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

```python
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`

```python
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)`

```python
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](#bulk).

<a id="namespaces"></a>

## 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`

```python
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`

```python
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`

```python
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)`

```python
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`

```python
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`

```python
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`

```python
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`

```python
client.namespaces.delete(NS_ID)
print("Gone?", not client.namespaces.validate_id(NS_ID))
```

**You get back:** `True` when deleted.

<a id="buckets"></a>

## 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`

```python
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](#workstations).

<a id="people"></a>

## 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`

```python
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`

```python
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`

```python
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`

```python
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](#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`

```python
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`

```python
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)`

```python
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`

```python
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=`.

<a id="roles"></a>

## 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](#people) 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`

```python
# 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`

```python
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`

```python
# 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`

```python
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.

```python
# 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.

<a id="policies"></a>

## 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](#roles) 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`

```python
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`

```python
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.

<a id="organisations"></a>

## 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`

```python
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`

```python
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.

<a id="user-groups"></a>

## 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](#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`

```python
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`.

<a id="api-keys"></a>

## 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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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](#auth).

<a id="builds"></a>

## 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`

```python
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.

<a id="manage"></a>

## 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

```python
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`

```python
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)`

```python
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.

```python
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`

```python
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`

```python
# EDIT — what is inside
handle.edit(updated_replicas=2)                                  # microservice: run 2 copies
handle.edit(configuration={"envs": [{"key": "LOG_LEVEL", "value": "debug"}]})
handle.edit(docker_image="myorg/app:2.0")                        # swap the image
handle.edit(resource_details={"CPU": 2, "memory": 4, "GPU": 0})

# RECONFIGURE — what it runs on
handle.reconfigure(plan_id=BIGGER_PLAN_ID)                       # move to another plan
handle.reconfigure(name="renamed-service")

handle.restart()        # apply the change
```

**You get back:** the updated record.

**Reconfigure takes different fields for each type.** Passing the wrong one fails immediately, with the allowed list in the message:

| Type | Fields it accepts |
|----|----|
| Microservice (`service`) | `plan_id`, `resource_details`, `parameters`, `updated_replicas`, `name` |
| Workstation (`image`) | `plan_id`, `resource_details`, `parameters`, `name` |
| AI model (`inference`) | `min_replicas`, `max_replicas`, `threshold`, `args`, `ports`, `envs` |
| Add-on (`addon`) | `min_workers`, `max_workers`, `head_plan_id`, `worker_plan_id` — **no** `plan_id` |
| Agent | `system_prompt`, `servers`, `context_length`, `available_sub_agents`, … |

Add-ons have **no** edit endpoint at all — resize them with `reconfigure()`.

### Share it with someone

**What this does:** gives other people access to this workload. You can pass emails, usernames or ids — the SDK looks up anything that is not already an id.

API `PUT /spawned-…/share/:id`

```python
# 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`

```python
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.

<a id="bulk"></a>

## 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)`

```python
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`

```python
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))`:

```python
# 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))
```

<a id="realtime"></a>

## 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`

```python
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`.

<a id="health-check"></a>

## 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.

```python
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.

<a id="errors"></a>

## 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**. |

<a id="good-habits"></a>

## 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.
