About lightning-artifacts
lightning-artifacts is a CLI-focused skill for publishing files to Lightning AI teamspaces and managing shareable links. It covers uploads, public or private access, listing and revoking links, and deleting stored files; published URLs do not expire.
Key features
- 01Upload files to a teamspace
- 02Publish public or private links
- 03List files and published links
- 04Revoke links without deleting files
- 05Delete files from storage
Use cases
- 01Sharing an HTML report by URL
- 02Publishing a PDF or one-page document
- 03Making CI output available at a link
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ lightning-ai/ skills --skill lightning-artifactsAfter installing, type/lightning-artifactsin Claude Code to confirm it loaded.
Details
From the project READMELightning AI Artifacts (durable shareable file links)
Publish a file to a teamspace and get a https://lightning.ai/artifacts/<id>
URL that anyone can open without a Lightning login. The control plane streams
the bytes from storage on every request, so unlike a presigned S3 URL the link
never expires (no ~1h cap).
The whole flow is CLI-only. lightning cp / ls / rm handle the files,
and lightning api makes the publish and unpublish calls. No Python or curl.
Setup & auth
export DEBUG=0 LIGHTNING_DEBUG=0 # an inherited DEBUG=1 logs the Authorization header; set this in every new shell
# Use the Lightning AI CLI from the current env; install or upgrade it there if it's missing or older than 2026.9.18
v=$(lightning --version 2>/dev/null | sed -n 's/^Lightning CLI version //p')
[ -n "$v" ] && [ "$(printf '%s\n' 2026.9.18 "$v" | sort -V | head -1)" = 2026.9.18 ] \
|| uv pip install -U lightning-sdk || python3 -m pip install -U lightning-sdk
lightning --version # must print "Lightning CLI version …"; if not, see the table below
lightning login # interactive browser sign-in — enough for everything here
# or: export LIGHTNING_API_KEY=... # non-interactive alternative (CI, agents)
Either credential works for every call in this skill — no token minting or
extra auth steps. Get a key from lightning.ai → user/org settings, or
lightning api-key create --org <org> --name artifacts. Never hardcode the
key — read it from the environment. To target a non-prod control plane, set
LIGHTNING_CLOUD_URL (default https://lightning.ai).
This uses, and if needed installs into, the agent's current environment (project venv, conda env,
…); then call plain lightning … everywhere. If setup doesn't go cleanly:
| Symptom | Fix |
|---|---|
No active env, externally-managed-environment, or a conflict with the project's own pins | Install it on its own: uv tool install lightning-sdk (or pipx install lightning-sdk), and call "$(uv tool dir --bin)/lightning" if that isn't on PATH |
Still no Lightning CLI version line after installing | Another lightning (PyTorch Lightning's) or an inactive env comes first: check command -v lightning, then activate the env or call its bin/lightning |
No such command, No such option or unexpected extra argument | The CLI is too old: upgrade it where command -v lightning points (uv pip install -U lightning-sdk, or uv tool upgrade lightning-sdk) |
| Installing is blocked (read-only env or home, agent sandbox) | Run each command as UV_CACHE_DIR="${TMPDIR:-/tmp}/uv" UV_TOOL_DIR="${TMPDIR:-/tmp}/uv-tools" uvx lightning-sdk … |
SSL/CA errors on every call, even lightning --version, only inside an agent sandbox | A **/*.pem read-deny rule hides certifi's cacert.pem: allow that one path; don't disable the sandbox |
NameResolutionError, only inside an agent sandbox | If lightning api fails too, ask the user to allow lightning.ai and *.lightning.ai (/sandbox). If only studio/job commands or the SDK fail, they bypass the sandbox proxy: run just those outside it. Background pollers fail the same way, silently |
lightning api flags: -X method, -f key=val string field, -F key=val
typed field, -H header, --input <file> request body (--input - to pipe
one), -q jq filter (needs the jq binary for -q), -i include response
headers, --silent no output. Fields with no -X make it a POST. Fields are JSON body for POST/PUT-with-body and query
params when the request also has --input or is a GET.
Resolve the teamspace (do this first)
Artifacts live in a teamspace (a "project" in the REST API). You need the
owner name and teamspace name (for lit:// paths) and the project
id (for REST calls). Never guess. If more than one membership fits and
none is configured, ask the user which to use:
lightning api /v1/memberships -q '.memberships[] | [.name, .projectId, .ownerType, .ownerId] | @tsv'
The membership carries only the owner's id. Resolve the name by ownerType:
PID=<projectId-from-above>
TSNAME=<name-from-above>
OWNER_ID=<ownerId-from-above>
if [ "<ownerType-from-above>" = organization ]; then
OWNER=$(lightning api "/v1/orgs/$OWNER_ID" | jq -r .name)
else # a personal teamspace: yours, or another user's you were added to
OWNER=$(lightning api /v1/users/search -X GET -f "query=$OWNER_ID" \
| jq -r --arg id "$OWNER_ID" '.users[] | select(.id==$id) | .username')
fi
[ -n "$OWNER" ] && [ "$OWNER" != null ] || echo "could not resolve the owner of $TSNAME — ask the user" >&2
The user search is fuzzy, so match .id exactly (the SDK does the same). Never
fall back to your own username: a wrong owner in the lit:// path uploads into
a different teamspace.
Teamspaces reached through org-level permissions don't appear in
/v1/memberships. If the user names one you can't find, ask them for the
<owner>/<teamspace> pair and the project id. Don't try to look it up by name:
/v1/projects?name=… doesn't exist.
Publish a durable link (the CLI flow)
A public link (private=false, the default below) needs the user's say-so in this request.
"Publish this" or "get me a public link" is authorization — don't ask twice. "Send this to the
team" is not: say what the link exposes and get a yes, or use -F private=true.
Three calls: upload into the artifacts/ drive, read back the storage cluster
it landed on, then register the object as a shared artifact. share and
shares (below) need the jq binary; without it, parse the JSON yourself.
# share <local-file> [remote-name] [content-type]
# Pretty status goes to stderr; the bare URL is the only thing on stdout, so
# URL=$(share file.html) captures cleanly while interactive use looks like:
# ✅ Published report.html (text/html; charset=utf-8)
# 🔗 https://lightning.ai/artifacts/art_...
share() {
local FILE="$1" NAME="${2:-$(basename "$1")}" CT="${3:-$(file -b --mime-type "$1")}"
local KEY="artifacts/${NAME#artifacts/}" # publish only finds objects under artifacts/
# 1. upload; the server picks where to store it (see Gotchas)
lightning cp "$FILE" "lit://$OWNER/$TSNAME/$KEY" >&2 || return 1
# 2. publish needs the blob's storage cluster, read from the listing (see Gotchas)
local CLUSTER; CLUSTER=$(lightning ls --json "lit://$OWNER/$TSNAME/$KEY" | jq -r '.[0].clusterId')
# 3. register it -> durable, no-expiry lightning.ai/artifacts/<id>
local LINK; LINK=$(lightning api "/v1/projects/$PID/shared-artifacts" -X POST \
-f clusterId="$CLUSTER" -f filename="$KEY" -f contentType="$CT" -F private=false -q .url | tr -d '"')
echo "✅ Published $NAME ($CT)" >&2
printf '🔗 ' >&2
echo "$LINK"
}
share report.html # -> https://lightning.ai/artifacts/art_...
share dashboard.html reports/dash.html text/html # custom remote name + explicit type
Always show the user the full URL on its own line, never just "done". Terminals make it clickable.
Shares are confined to the artifacts/ folder. Publish only finds objects
under lit://<owner>/<teamspace>/artifacts/.... Files under uploads/ or
lightning_storage/ can be copied with lightning cp, but publish won't find
them. The KEY variable keeps the upload path and the publish filename
identical. A bare filename gets artifacts/ prepended by the server, but
matching the two explicitly is clearest.
The default private=false link is open to anyone who has it, with no
auth. Set -F private=true so only authorized project readers can open it.
Content types that render inline
The publish call's contentType decides whether HTML/PDF render or
download. It is set at publish time, not upload time, and overrides the
stored object's type on every request. file -b --mime-type guesses most
cases; pass an explicit type when the extension is ambiguous.
| File | Content-Type |
|---|---|
.html | text/html; charset=utf-8 |
.pdf | application/pdf |
.svg | image/svg+xml |
.png / .jpg | image/png / image/jpeg |
.json / .txt / .csv | application/json / text/plain / text/csv |
| anything to force-download | application/octet-stream |
List, see what's published, unpublish, delete
lightning ls lists the drive completely, even for folders with many
thousands of files. It follows the server's pages. It shows one level by
default, -r for every file underneath, and --json for entries with size
and clusterId:
lightning ls "lit://$OWNER/$TSNAME/artifacts/" # one level; folders end in /
lightning ls -r "lit://$OWNER/$TSNAME/artifacts/reports" # full relative file paths
lightning ls --json "lit://$OWNER/$TSNAME/artifacts/reports"
shares lists every published link in the project, newest first. It calls
GET /v1/projects/{pid}/shared-artifacts, which returns id, filename, content
type, public/private, download count and URL:
# shares -> one block per published artifact
shares() {
lightning api "/v1/projects/$PID/shared-artifacts" | jq -r '.artifacts // [] | .[] |
"\(if .private then "🔒" else "🌐" end) \(.filename) · ⬇ \(.downloads // 0) · \(.createdAt // "")
id: \(.id)
🔗 \(.url)"'
}
unshare revokes a link but keeps the file in the drive:
# unshare <artifact-id>
unshare() {
lightning api "/v1/projects/$PID/shared-artifacts/$1" -X DELETE --silent \
&& echo "🗑️ Unpublished $1 — link is dead, file kept in the drive" >&2
}
unshare art_01kxgaep54zzs84arfns1j21wd # illustrative id; take yours from shares
lightning rm deletes the file itself, with no cluster id. The server
removes it wherever it is stored. Use it after unpublishing, or to clean up an
abandoned upload:
lightning rm "lit://$OWNER/$TSNAME/artifacts/report.html"
lightning rm -r "lit://$OWNER/$TSNAME/artifacts/reports" # a folder and everything under it
rm fails on a path that doesn't exist (pass -f to ignore) and refuses a
folder without -r.
Re-running lightning cp to the same artifacts/<name> updates the bytes
behind existing links. Their ids keep serving the new contents. Repeating the
publish call with the same filename gives a new id and URL; do that to change
the served Content-Type.
Example workflows
Prompts this skill handles: "share this HTML report as a permanent link", "give me a public URL for output.pdf", "publish this one-pager so I can send it", "drop this file somewhere CI can curl it".
An agent-generated HTML one-pager needs only share; anyone can open the printed URL:
share summary.html # hand the printed URL to anyone
A loop over share publishes a folder of reports, one link each:
for f in out/*.html; do share "$f" "reports/$(basename "$f")" "text/html; charset=utf-8"; done
share prints only the URL on stdout, so a CI job can capture and curl it:
URL=$(share model-metrics.json)
# elsewhere: curl -sL "$URL" -o metrics.json
Gotchas
- Publishing with a compute cluster's id fails with HTTP 500; publish needs
the blob's storage cluster. A teamspace can be bound to compute clusters
that store files under a parent cluster's bucket, so the upload cluster is not
always the storage cluster. Read the real
clusterIdfrom the listing, assharedoes. lightning cpneeds no cluster flag, because the server picks the storage. Only if the server rejects the upload for a missing cluster doescpchoose one itself (LIGHTNING_CLUSTER_IDfirst, which is set inside a Studio, then the teamspace's only or default cloud account) and warnNo cloud account specified. Using cloud account: <id>.Pass--cloud-account <id>only to steer placement deliberately, and pick a cluster whosestatus.phaseisCLUSTER_STATE_RUNNING(/v1/projects/$PID/clusters). A bound but unusable cluster makes the upload fail with a drive error that says nothing about cluster health.- Unpublish reports success for links that don't exist.
DELETE /v1/projects/{pid}/shared-artifacts/{id}returnsHTTP 200with{}for an id that was already revoked or mistyped. Exit code 0 is not proof; check that the public URL 404s. (lightning rm, by contrast, fails on a missing path unless you pass-f.) - Served HTML is not byte-identical to the upload, so don't checksum it
against the source. Cloudflare injects a Browser-Insights RUM beacon
(
static.cloudflareinsights.com/beacon.min.js) into HTML responses. It is harmless for viewing, but don't promise a bit-exact document. sharesfails with HTTP 501 "Method Not Allowed" on control planes built before mid-July 2026. The list endpointGET /v1/projects/{pid}/shared-artifactsis newer than the rest. Publish and unpublish still work there.- A public link is open to anyone who has it, and revoking it doesn't recall
a copy already fetched. Use
-F private=truefor anything that shouldn't be world-readable.