Documentation
See it in action — browse live examples on the @skua profile page.
Installation
pip install getskuaThat's it. Skua uses your installed libraries to serialize your objects so there will never be any version conflicts or compatibility issues. Works with matplotlib, plotly, pandas, polars, numpy, PIL, PyTorch, TensorFlow, any object with a rich HTML repr (Altair, Bokeh, folium, styled DataFrames, great_tables), and any object with a string representation.
Quick Start
import skua
import matplotlib.pyplot as plt
q3 = skua.collection("Q3 Review")
fig, ax = plt.subplots(figsize=(10, 5))
ax.bar(x, revenue, label="Revenue", ...)
ax.bar(x, costs, label="Costs", ...)
ax.plot(x, profit, "o-", label="Profit")
# ...
q3.record(fig, title="Revenue vs Costs", description="...", tags=[...])Share the URL with anyone. Re-run q3.record() with the same title to update in place — same URL, fresh results. This is also the easiest way to change a record's visibility: just call it again with a different visibility=.
Skip the named handle entirely if you don't need scoping — bare skua.record(fig, title="...") lands in your per-user Default collection.
Collections
A collection is the named bucket that records live in. Open one with skua.collection("name") and call .record(...) on the handle.
Collections do four things:
- Grouping — every collection has its own page at
/c/{id}, listing the records inside it. Share the collection URL to share a whole notebook's worth of output as a single link. - Visibility — collections have their own access gate (
public/unlisted/private), independent of the records inside. See Visibility for the full model. - Update-in-place — records dedupe by
(collection, title). Re-runningq3.record(chart, title="Revenue")updates that record and keeps its URL stable, so shared links stay live while you iterate. - Per-user Default —
skua.record()(no handle) writes to a per-user "Default" collection. Useful for scratch records you want to share via link but not organize.
skua.collection(name) resolves once per process — calling it again with the same name returns the same handle without a network roundtrip. Pass visibility= on the first call to set the collection's access gate (see Visibility). The visibility kwarg is a creation hint: passing it on a subsequent call with a value that conflicts with the persisted visibility raises ConfigurationError — either drop the kwarg, or pick a different name to make a new collection.
Visibility
Records and collections have three visibility levels. The default for both is unlisted.
| Level | /r/{id} (record) | /c/{id} (collection) | Listed on /u/{handle} profile? | |---|---|---|---| | public | Anyone | Anyone | Yes | | unlisted (default) | Anyone with the URL | Anyone with the URL | No | | private | Owner only | Owner only | No |
Record IDs are 12 base62 chars (~71 bits of entropy), so an unlisted URL is functionally unguessable — sharing the link is what makes it readable, not the visibility level. Use unlisted when you want "anyone with this link can see it" without showing up in profile listings. Use public only when you actively want the record discoverable via your profile.
Per-record visibility wins for /r/{id} access. Collection visibility just gates who sees the listing at /c/{id} — a public record inside a private collection is still readable at its own URL by anyone (the link is the capability). To make a record inaccessible to non-owners, set the *record's* visibility to private, not just the collection's.
private requires a verified account. Anonymous users can create public and unlisted records and collections; passing visibility="private" raises UploadError asking you to verify your email first. Anonymous "privacy" would be bound to a single ~/.skua/client token file with no recovery — lose the file, lose access forever. Verifying an email turns that into a recoverable account.
Changing visibility after the fact uses the API — there's no SDK kwarg for in-place edits. Re-running skua.record() with the same (collection, title) and a different visibility= works (it's an update-in-place), but the PATCH boundary is stricter: anonymous accounts can only PATCH to public. Moving a record back to unlisted or private after publication requires a verified account.
Missing-token viewers see 404, not 403, on a private record. The existence of a private record ID isn't leaked to probers — non-owners get the same response as for an ID that never existed. Same rule for private collections.
API Reference
skua.collection()
skua.collection(name, *, visibility=None) -> CollectionReturn a handle to a named collection. First call within a process performs a synchronous backend roundtrip to create-or-get the collection row; subsequent calls with the same name in the same process return the cached handle for free. See Collections above for what collections do and why.
namestr, 1–100 charsCollection name. Pairs with each record's title as the upsert key on (collection, title), so titles within one collection are unique while different collections can have the same titles. The name is a label only — it does not appear in any URL.
visibility"public" | "unlisted" | "private", default "unlisted"The collection's access gate. See Visibility. Persisted server-side on first creation; re-passing a different value on a later call raises ConfigurationError.
class Collection:
id: str # 12-char base62
name: str
visibility: str # "public" | "unlisted" | "private"
url: str # canonical /c/{id} URL — shareable
def record(self, obj, title, **kwargs) -> Record: ...skua.record()
skua.record(obj, title, description=None, visibility=None, tags=None) -> RecordCapture and share a Python object. Without a Collection handle, records land in the per-user Default collection.
objObject to record (matplotlib/plotly figure, pandas/polars DataFrame, PIL image, PyTorch/TF tensor, list of dicts, any object with a rich HTML repr like Altair/Bokeh/folium/styled DataFrames, or any object with a string representation).
titlestr, max 500 charsRecord title. Recording again with the same title in the same collection updates the existing record in place — same URL, replaced content. Vary the collection if you want history (e.g. one collection per period).
descriptionstr, max 1000 chars, default NoneShort description providing context (1-3 sentences recommended).
visibility"public" | "unlisted" | "private", default "unlisted"Per-record access gate, authoritative for /r/{id}. See Visibility.
tagslist[str], max 20 tags, 30 chars each, default NoneTags for categorization. Empty strings are ignored, whitespace is stripped.
class Record:
url: str # shareable URL
metadata: dict[str, Any] # id, creator_username, raw_url, ...In Jupyter, the original object is displayed inline.
skua.login()
skua.login() -> NoneOpen the browser to verify your email. Verified accounts get 365-day retention (vs 30 days for anonymous), no per-account record cap, and a public profile page at skua.dev/u/username.
skua.auth()
skua.auth(token) -> NoneSave a token received by email after calling skua.login().
tokenstr, starts with sk_The verification token from the email.
skua.status()
skua.status() -> StatusCheck current authentication status.
class Status(TypedDict):
verified: bool
email: str | None
username: str
retention_days: intskua.open_profile()
skua.open_profile(open_browser=True) -> strOpen your profile page in a browser. Verified users get a short-lived one-click login URL so the browser lands already signed in (unlisted and private records visible); anonymous users get the bare public profile URL. Returns the URL.
open_browserbool, default TrueIf False, returns the URL without calling webbrowser.open(). Useful in headless/CI contexts.
CLI
skua record chart.png --title "Q3 Revenue"
skua record data.csv --title "Sales Data" --public --tags "finance,q3"
skua record plot.json --title "Interactive Chart" --collection "Q3 Review"
cat report.txt | skua record - --type text --title "Analysis"Use --json for machine-readable output:
skua record chart.png --title "Chart" --json
# {"url": "https://skua.dev/r/abc123", "id": "abc123", "visibility": "public"}Other commands:
skua login # open browser to verify email
skua verify sk_... # paste the token from the verification email
skua status # show current auth state (verified / anonymous, username)
skua list # list your records (use --json for machine-readable)
skua open <id> # open a record URL in the browserSupported Types
Matplotlib figures
Saved as high-DPI PNG images. Pass any plt.Figure object.
import skua
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(*setosa.T, label="Setosa", alpha=0.7)
ax.scatter(*versicolor.T, label="Versicolor", alpha=0.7)
ax.scatter(*virginica.T, label="Virginica", alpha=0.7)
ax.set_xlabel("Sepal Length (cm)")
ax.set_ylabel("Sepal Width (cm)")
ax.set_title("Iris Dataset — Sepal Dimensions")
ax.legend()
skua.record(fig, title="Iris Scatter Plot", description="...", tags=[...])
# > https://skua.dev/r/ZKvb4nbrY5tJPlotly figures
Rendered as fully interactive charts — zoom, pan, hover tooltips, and PNG export.
import skua
import plotly.graph_objects as go
fig = go.Figure()
fig.add_trace(go.Scatter(x=years, y=anomaly, ...))
skua.record(fig, title="Global Temperature Anomaly", description="...", tags=[...])
# > https://skua.dev/r/Vb3gk1Io8ClkPandas DataFrames
Rendered as interactive, sortable, filterable tables.
import skua
import pandas as pd
df = df.set_index("ticker")
# df = pd.DataFrame({...}) # 30 stocks
skua.record(df, title="S&P 500 Snapshot", description="...", tags=[...])
# > https://skua.dev/r/POpsKf8aR58xPolars DataFrames
Same interactive table rendering. LazyFrames are collected automatically.
import skua
import polars as pl
# df = pl.DataFrame({...}) # 8 chips
skua.record(df, title="CPU Benchmarks", description="...", tags=[...])
# > https://skua.dev/r/zVNnwm0hPIBJList of dicts
Common output from HuggingFace pipelines and eval loops — rendered as a table.
import skua
# results = pipeline("sentiment-analysis")(texts)
skua.record(results, title="Sentiment Analysis", description="...", tags=[...])
# > https://skua.dev/r/9yMKCJOEtEzUPIL Images
Any PIL.Image object, saved as PNG.
import skua
from PIL import Image
import numpy as np
w, h, max_iter = 960, 640, 80
x = np.linspace(-2.5, 1.0, w)
y = np.linspace(-1.2, 1.2, h)
C = x[np.newaxis, :] + 1j * y[:, np.newaxis]
Z = np.zeros_like(C)
M = np.zeros(C.shape, dtype=int)
for i in range(max_iter):
mask = np.abs(Z) <= 2
Z[mask] = Z[mask] ** 2 + C[mask]
M[mask] = i
t = M / max_iter
r = (np.sin(3.0 * t * np.pi) * 127 + 128).astype(np.uint8)
g = (np.sin(3.0 * t * np.pi + 0.8) * 127 + 128).astype(np.uint8)
b = (np.sin(3.0 * t * np.pi + 1.6) * 127 + 128).astype(np.uint8)
rgb = np.stack([r, g, b], axis=-1)
img = Image.fromarray(rgb)
skua.record(img, title="Mandelbrot Set", description="...", tags=[...])
# > https://skua.dev/r/REtywyKymskGPyTorch & TensorFlow tensors
Image tensors (CHW or BCHW) are rendered as PNG. Other tensors are displayed as tables.
Rich HTML outputs
When an object doesn't match a dedicated handler, Skua falls back to whatever it renders in Jupyter — any object exposing _repr_html_ or _repr_mimebundle_ is captured as HTML and replayed on the record page, the same way a Plotly figure is captured and re-rendered. That makes it the catch-all for rich outputs: Altair, Bokeh, folium, styled DataFrames (df.style), great_tables, IPython.display.HTML, and anything else that knows how to draw itself as HTML. Static HTML renders in a sandboxed iframe exactly as it looks in the notebook; script-driven interactive outputs render behind a one-click "Render output" gesture.
A styled DataFrame keeps its gradients, highlights, and conditional formatting:
import skua
import pandas as pd
# df = pd.DataFrame({...}) # 4 teams
styled = (
df.style
.background_gradient(subset=["wins"], cmap="Greens")
.highlight_max(subset=["points_for"], color="#2a9d8f")
.set_table_attributes('style="width:100%"')
)
skua.record(styled, title="League Standings", description="...", tags=[...])
# > https://skua.dev/r/c4tgKAbW1qNrInteractive charts from libraries like Altair render behind a one-click gesture, then become fully interactive:
import skua
import altair as alt
import pandas as pd
# cars = pd.DataFrame({...}) # 12 cars
chart = alt.Chart(cars).mark_circle(size=120).encode(
x="horsepower",
y="mpg",
color="origin",
tooltip=["horsepower", "mpg", "origin"],
).properties(title="Horsepower vs Fuel Economy", width="container", height=420)
skua.record(chart, title="Altair Scatter", description="...", tags=[...])
# > https://skua.dev/r/RBsy46gY7eucText & strings
Any object with a string representation. Full Unicode support — em-dashes, accented characters, CJK, and emoji all render correctly.
import skua
skua.record("¥127.4M", title="Q4 Revenue", description="...", tags=[...])
# > https://skua.dev/r/AbjLdSp9TU8VAnonymous Usage
No account required. Anonymous usage has the following limits:
- Records expire after 30 days
- 10 records per device
- 20 uploads per hour
- 10 MB max per record
Validated Accounts
Verify your email to unlock longer retention and higher limits:
- 365-day retention per record — each record's clock resets when you update it
- No per-account record cap
- 10 MB max per record
Run skua.login() in your notebook to start the verification flow. Enter your email to receive a verification link.
Support
Questions or feedback? Email [email protected] or open an issue on GitHub.