Guide
Humans may read this to get started. Agent skills are available to install from the nebo CLI.
This guide walks through building observable Python pipelines with Nebo, from basic to advanced usage.
Logging
Nebo provides several logging functions.
Text Logs
nb.log(message, *, name="text") logs a plain text message as a named stream. The name parameter (default "text") identifies the stream in the Tracker tree; passing different names creates separate streams within the same loggable. Tensor-like objects (NumPy arrays, PyTorch tensors) are auto-formatted with shape, dtype, and statistics:
nb.log("Starting training...")
for epoch in range(10):
loss = train_epoch(model, data)
nb.log(f"Epoch {epoch}: loss={loss:.4f}")
Metric charts
Nebo has one logging function per chart type — nb.log_line for
scalars over time, nb.log_bar and nb.log_pie for snapshot
distributions, nb.log_histogram for labeled distributions, and
nb.log_scatter for labeled 2-D point clouds. The chart type locks
on first emission per (loggable, name) pair, so reusing a name
with a different log_* function raises ValueError.
log_line and log_scatter accumulate over time —
re-emitting with the same name appends to the series. Both auto-
increment step per (loggable, name) when omitted:
def train(model, data):
for epoch in range(100):
loss = train_epoch(model, data)
accuracy = evaluate(model, data)
nb.log_line("loss", loss)
nb.log_line("accuracy", accuracy)
log_scatter takes a labeled point dict and lets the UI toggle each
label on or off via the chip row above the chart. Repeated calls add
more points to the same plot, with each emission’s points tagged with
the auto-incrementing step:
for i, (point, cluster) in enumerate(detections):
nb.log_scatter("embed_2d", {cluster: [point]}) # step auto-advances
log_bar, log_pie, and log_histogram are snapshots —
every re-emission overwrites the prior value. They don’t accept
step or tags (those concepts apply to the accumulating
helpers).
log_histogram accepts {label: list[number]} — every label is
its own distribution, all binned against a shared range so overlaps
line up. log_scatter and log_histogram also accept
colors: bool = False; setting colors=True distinguishes labels
by palette color (in addition to per-label shapes for scatter), but
is not recommended in comparison views where the palette is reserved
for run identity.
Step filtering across panels
- In the web UI, you can filter your view of data by:
clicking any datapoint on a line or scatter chart
entering a step directly in the Tracker controls (bottom panel)
stepping with the prev/next arrows or Ctrl/⌘+Left/Right
Use the Tracker’s Clear all filters button to clear the filter. Bar/pie/histogram are stepless and stay visible when the filter is active.
Images
nb.log_image(image, name=None, step=None) accepts PIL images, NumPy arrays, or PyTorch tensors:
def augment(image):
result = apply_transforms(image)
nb.log_image(result, name="augmented")
return result
Audio
nb.log_audio(audio, sr=16000, name=None) logs audio data as NumPy arrays:
def synthesize(text):
waveform = tts_model(text)
nb.log_audio(waveform, sr=22050, name="speech")
return waveform
Configuration
Note
Only supported in decorated functions. Use nb.start_run to log global-level config.
nb.log_cfg(cfg) logs configuration for the current node.
def train(lr=0.001, epochs=50):
nb.log_cfg({"lr": lr, "epochs": epochs})
...
Multiple log_cfg() calls within the same node merge their dictionaries.
Progress Tracking
nb.track(iterable, name, total) wraps an iterable for tqdm-like progress tracking. The terminal dashboard and UI render a live progress bar:
def process(items):
results = []
for item in nb.track(items, name="processing"):
results.append(transform(item))
return results
If the iterable has a __len__, the total is auto-detected. Otherwise you can pass total explicitly:
for batch in nb.track(dataloader, name="training", total=len(dataloader)):
...
Scopes
Global
All prior logging examples were writing to a "__global__" scope as calls made at module scope or from
non-decorated helpers land on the Global loggable.
The Global loggable appears as a distinct card at the top of the flat view (labelled “List” on mobile) and is excluded from the DAG view — it is not a node. Its tabs (Logs, Metrics, Images, Audio) work identically to any node’s tabs.
Example:
import nebo as nb
nb.log("environment looks good") # → Global
nb.log_line("warmup_heartbeat", 1.0) # → Global
Function
Nebo also supports function-level logging, where log statements under the @nb.fn() decorator will land under its associated function’s scope.
Example:
@nb.fn()
def train():
for batch in nb.track(dataloader, name="training", total=len(dataloader)): # → train
loss = model(batch)
nb.log_line("loss", loss) # → train
Decorating with @nb.fn()
The @nb.fn() decorator is a core primitive. It registers a function for scope tracking in the pipeline DAG. A node only materializes (appears in the DAG) if the logging function executes. Edges are inferred from data flow: when a node’s return value is passed as an argument to another node, an edge is created from the producer to the consumer.
import nebo as nb
@nb.fn()
def load_data():
"""Load raw data."""
nb.log("Loading data")
return [1, 2, 3]
@nb.fn()
def transform(data):
"""Transform data."""
nb.log(f"Transforming {len(data)} items")
return [x * 2 for x in data]
def run():
records = load_data()
result = transform(records) # edge: load_data -> transform (data flow)
return result
The decorator can be used in several forms:
@nb.fn # bare (no parentheses)
@nb.fn() # empty parentheses
@nb.fn(depends_on=[setup]) # with explicit dependencies
@nb.fn(ui={"color": "#34d399"}) # with per-node UI hints
DAG Strategy
Switch how edges are inferred via nb.init(dag_strategy=...):
"object"(default) — data-flow edges (A → BwhenBreceives an argument produced byA), with caller→callee as fallback."stack"— caller→callee only; arguments ignored. Use when nodes share state through globals or class attributes."both"— union ofobjectandstack. Busy but thorough."linear"— chain nodes in first-execution order. Good for demos and notebooks."none"— no auto edges; onlydepends_on=[...]adds them.
The same three-node script under dag_strategy="stack" — the
data-flow edge between load and transform vanishes; run
fans out to both:
Explicit Dependencies with depends_on
Some dependencies cannot be detected automatically — shared mutable state, class attributes, closures, or global variables. Use depends_on to declare these explicitly:
@nb.fn()
def setup():
"""Initialize shared resources."""
nb.log("Setting up")
@nb.fn(depends_on=[setup])
def process():
"""Uses resources initialized by setup."""
nb.log("Processing")
depends_on accepts a list of decorated functions or node ID strings. Explicit dependencies are added alongside any auto-detected data-flow edges.
Note
With dag_strategy=”object”, Nebo tracks data flow via argument passing (id()-based return value tracking). Dependencies through shared mutable state, class attributes, closures, or global variables are not automatically detected. Use depends_on for these cases.
Decorating Classes
@nb.fn() can also be applied to a class. All methods are wrapped with scope tracking. The class itself is never a node — it serves as a visual grouping container (transparent bounding box) in the DAG.
import nebo as nb
@nb.fn()
class DataPipeline:
def load(self):
nb.log("Loading data")
return [1, 2, 3]
def transform(self, data):
nb.log(f"Transforming {len(data)} items")
return [x * 2 for x in data]
def save(self, data):
nb.log(f"Saving {len(data)} items")
In the DAG, DataPipeline appears as a transparent bounding box containing load, transform, and save as individual nodes.
Scoping rules:
Every method gets its own scope. Logs inside
transform()are scoped toDataPipeline.transform.Every method that runs materializes as a node, including silent methods that never call a log function — this keeps dependency chains in the DAG intact even when an intermediate method only orchestrates calls to other nodes.
If a method also has
@nb.fn()on it, a warning is issued (the decorator is redundant).A standalone
@nb.fn()function called from within the class also appears inside the class group.A decorated method in an undecorated class is a regular standalone node with no bounding box.
Workflow Description
nb.md(description) sets a Markdown description for the overall workflow. This is visible in MCP tools and the terminal dashboard:
nb.md("""
# Image Classification Pipeline
Loads images from disk, runs inference with a pretrained ResNet,
and exports predictions to a JSON file.
""")
Calling nb.md() multiple times appends to the description.
UI Configuration from Code
nb.ui() sets run-level UI defaults. The web UI reads these as defaults that the user can override:
nb.ui(
layout="horizontal", # or "vertical"
view="dag", # or "flat"
minimap=True, # show minimap
theme="dark", # or "light"
)
Per-node display hints can be set via @nb.fn(ui={}). Supported keys:
color(str) — accent color for the node’s badge / border.default_tab(str) — which tab opens by default for the node. One of"logs","metrics","images","audio". The user’s clicks always override this preference.
Unknown keys are forwarded verbatim for forward compatibility with future UI features.
@nb.fn(ui={"color": "#fb923c"})
def data_loader():
nb.log("Loading data")
...
@nb.fn(ui={"default_tab": "metrics"})
def train(epochs=100):
for step in range(epochs):
nb.log_line("loss", compute_loss(step))
Execution Modes
Nebo has two transports, selected by the uri= argument to
nb.init() (or the NEBO_URI environment variable):
File Mode (Default)
When you run a script directly (python my_pipeline.py), nebo
writes events to ./.nebo/<timestamp>_<run_id>.nebo — an
append-only file. No daemon is required. Point nebo serve --logdir
<dir> at the directory later to inspect runs in the web UI.
You can change the output directory explicitly:
import nebo as nb
nb.init(uri="runs/today/")
Network Mode
When the uri is an HTTP URL or a host:port pair, nebo streams
events to a running daemon over HTTP instead of writing files
locally:
import nebo as nb
nb.init(uri="localhost:7861")
# or
nb.init(uri="https://my-space.hf.space", api_token="nb_...")
Persistent .nebo Files
In file mode the SDK writes .nebo/<timestamp>_<run_id>.nebo
directly. To make a particular invocation a no-op (no file opened),
set NEBO_NO_STORE=1 — used by the test suite.
In network mode the daemon stays in-memory by default. Pass
--save-files PATH to persist incoming events to disk:
nebo serve --save-files ./.nebo/
The watcher (--logdir) and the writer (--save-files) can’t
share a directory — the daemon refuses to start if they resolve to
the same path.
To load a .nebo file into a local daemon for viewing and Q&A:
nebo load path/to/run.nebo
To load a file into a remote daemon (e.g. one running on a
Hugging Face Space), pass --url. The file is read locally and
its events are replayed through /events because the remote
daemon can’t see your filesystem:
nebo load path/to/run.nebo \
--url https://username-space.hf.space \
--api-token nb_…
NEBO_URL and NEBO_API_TOKEN env vars work as defaults so
you don’t have to repeat the flags.
MCP Integration for AI Agents
Nebo includes 21 MCP tools that allow AI agents (like Claude) to run, monitor, debug, query, and push data into pipelines. To set up MCP:
Start the daemon:
$ nebo serve -dGet the MCP config:
$ nebo mcpAdd the printed config to your Claude Desktop or Claude Code MCP configuration.
The tools fall into three buckets:
Observation —
nebo_get_graph,nebo_get_loggable_status,nebo_get_logs,nebo_get_metrics,nebo_get_errors,nebo_get_description,nebo_get_run_status,nebo_get_run_history.Alerts & utility —
nebo_wait_for_alert,nebo_list_alerts,nebo_set_alert,nebo_delete_alert,nebo_load_file. Alert rules fire on metric conditions (e.g.train/loss > 5) without any code changes; pipelines start/stop via the user’s shell.Write —
nebo_log_metric,nebo_log_image,nebo_log_audio,nebo_log_text. These mirror the SDK’snb.log_*helpers so an external agent can push metrics, media, and text into a run without owning the SDK process. URL-based media is fetched server-side and persisted via the existing media path so runs stay self-contained even when the source URL goes stale.
An AI agent can use these to autonomously run experiments, diagnose failures, patch code, ask questions about runs, and iterate.
Q&A Chat
Nebo supports querying runs using natural language. From the web UI, users can open the Chat tab in the right panel and ask questions like “how did my training run go?” or “what metrics are underperforming?”
The daemon delegates Q&A to Claude Code CLI, spawning it as a subprocess with MCP config pointing back to itself. Claude Code reads the run’s state via MCP tools and generates an answer.
This requires Claude Code CLI to be installed on the system where the daemon runs.
Notebook Embedding via nb.show()
In a Jupyter / IPython context, nb.show() returns an inline
<iframe> pointing at the running daemon. The slice rendered is
inferred from which kwargs you pass — there is no view=
discriminator. Pick at most one of metric / image / audio
/ logs / dag; pass nothing for the full run dashboard.
import nebo as nb
nb.show() # full run
nb.show(node="train") # single node detail
nb.show(node="train", metric="loss") # one metric, scoped to a node
nb.show(metric=True) # gallery of all metrics
nb.show(logs=True) # logs panel
nb.show(dag=True) # DAG only
Each call maps to a URL the iframe loads — the same query-param scheme the dashboard accepts directly:
nb.show()→?run=<id>nb.show(node="t")→?run=<id>&node=tnb.show(metric="loss")→?run=<id>&metric=lossnb.show(metric=True)→?run=<id>&metricsnb.show(image="hero.png")→?run=<id>&image=hero.pngnb.show(audio=True)→?run=<id>&audiosnb.show(logs=True)→?run=<id>&logsnb.show(dag=True)→?run=<id>&dag
When the daemon enforces auth, append &token=… to the URL — the
dashboard captures it once on first load, persists it in
localStorage, and strips it from the visible URL via
replaceState.
Hosting on Hugging Face Spaces
Nebo’s daemon is the same FastAPI app whether it runs on your laptop,
in CI, or on a Hugging Face Space. nebo deploy bundles a
Docker-SDK Space, sets the necessary secrets, and gives you the
endpoint URL plus a token to share with the SDK.
Install the optional deploy extra and authenticate:
pip install 'nebo[deploy]'
huggingface-cli login # writes a token write-scoped for your account
Deploy to a new (or existing) Space:
nebo deploy --space-id <user>/nebo-test --from-source
The CLI prints the public URL and a randomly-generated
NEBO_API_TOKEN. Save it — the deploy won’t show it again. Use
--api-token <tok> to supply your own.
Connect the SDK from anywhere:
import nebo as nb
nb.init(
uri="https://<user>-nebo-test.hf.space",
api_token="nb_…",
)
@nb.fn()
def step():
nb.log("hello from a remote Space")
nb.log_line("loss", 0.42)
step()
Or set NEBO_URI / NEBO_API_TOKEN in the environment so the
same script works locally and remotely without a code change.
Access modes
The deployed daemon defaults to public reads, private writes —
anyone with the URL can view runs in the dashboard, but only token
holders can push events or control runs. Override with the
--read / --write flags:
nebo deploy --space-id <user>/private-dash \
--read private --write private \
--from-source
Mode |
|
|
|---|---|---|
Public dashboard |
|
|
Private dashboard |
|
|
Read-only mirror |
|
|
Embed slices on a website
The same iframe URL scheme used by nb.show() works against the
deployed Space. Anything that renders HTML can host a live slice:
<iframe
src="https://<user>-nebo-test.hf.space/?run=<id>&metric=loss"
width="100%" height="600">
</iframe>
For private dashboards, append &token=… once — the dashboard
caches it for subsequent visits.
Loading a local file into a deployed Space
If you have a .nebo file from a local run and want it visible on
the Space:
export NEBO_URL=https://<user>-nebo-test.hf.space
export NEBO_API_TOKEN=nb_…
nebo load path/to/run.nebo --url "$NEBO_URL"
The events are read locally and replayed through /events on the
remote daemon (the daemon’s POST /load only accepts server-side
paths, which don’t help when the file is on your laptop).
Complete Example: Data Processing Pipeline
import numpy as np
import nebo as nb
nb.md("# Data Processing Pipeline\nGenerate, normalize, filter, and analyze data.")
@nb.fn()
def generate(num_samples: int = 200, noise: float = 0.1, seed: int = 42):
"""Generate synthetic signal data."""
nb.log_cfg({"num_samples": num_samples, "noise": noise, "seed": seed})
np.random.seed(seed)
t = np.linspace(0, 4 * np.pi, num_samples)
signal = np.sin(t) + noise * np.random.randn(num_samples)
nb.log(f"Generated {num_samples} samples")
return signal
@nb.fn()
def normalize(data, method: str = "standard", clip_min: float = -3.0, clip_max: float = 3.0):
"""Normalize and clip the signal."""
nb.log_cfg({"method": method, "clip_min": clip_min, "clip_max": clip_max})
if method == "standard":
data = (data - data.mean()) / (data.std() + 1e-8)
data = np.clip(data, clip_min, clip_max)
nb.log(f"Normalized with method={method}, clipped to [{clip_min}, {clip_max}]")
return data
@nb.fn()
def analyze(data):
"""Compute statistics on the processed data."""
stats = {"mean": float(data.mean()), "std": float(data.std()), "n": len(data)}
nb.log(f"Stats: mean={stats['mean']:.4f}, std={stats['std']:.4f}")
nb.log_line("mean", stats["mean"])
nb.log_line("std", stats["std"])
return stats
def run():
"""Main entry point."""
data = generate()
normed = normalize(data)
return analyze(normed)
if __name__ == "__main__":
result = run()
print(result)
More Examples
Runnable examples live in the examples directory of the repository.