Documentation menu

Read it before you run it.

# piping a script from the internet into your shell is a real thing to be careful about. so here is all of it, in full, on one page.

Two things touch your machine: a shell script that downloads one binary, and a markdown file that tells your agent what to do. Both are below in their entirety — not a summary, not an excerpt. The script text is rendered from the same constant the server sends, so this page cannot drift from what curl actually gives you.

Prefer to check for yourself? Diff them: curl -fsSL https://launch80.com/install.sh | shasum -a 256

What these do, and what they never do

The installer writes exactly one file. A single binary into the prefix you choose, plus a lowercase alias on case-sensitive filesystems. It never touches your shell profile — if the directory isn’t on your PATH it prints the line for you to add yourself.
It verifies before it installs. It downloads the checksum file alongside the archive and refuses to install on a mismatch. If no sha256 tool exists it says so on stderr rather than quietly skipping.
It sends nothing about you. Two GETs to GitHub Releases. No telemetry, no analytics, no identifier, no callback to launch80.
Your token never enters a model’s context. The CLI reads it from disk or the environment at the moment of the request. The skill explicitly instructs the agent never to ask you to paste one into a chat.
The CLI only ever talks to one host. Whatever L80_API_BASE resolves to, default launch80.com. It posts the bytes of the file you name, verbatim, and prints what comes back.
The binary has no third-party dependencies. Go standard library only. A process that holds a credential inherits no supply chain it doesn’t need.

The install script

served at /install.sh →
sh136 lines
#!/bin/sh
# Installer for the L80 CLI — https://launch80.com/skills
#
# Usage:
#   curl -fsSL https://launch80.com/install.sh | sh
#   curl -fsSL https://launch80.com/install.sh | sh -s -- --prefix /usr/local/bin
#
# Reads nothing, sends nothing, and installs exactly one file.

set -eu

REPO="launch80/L80-Skills"
BINARY="L80"
PREFIX="${L80_INSTALL_DIR:-$HOME/.local/bin}"
VERSION="latest"

while [ $# -gt 0 ]; do
  case "$1" in
    --prefix) PREFIX="$2"; shift 2 ;;
    --version) VERSION="$2"; shift 2 ;;
    -h|--help)
      echo "usage: install.sh [--prefix DIR] [--version vX.Y.Z]"
      exit 0 ;;
    *) echo "install.sh: unknown option $1" >&2; exit 2 ;;
  esac
done

die() { echo "install.sh: $*" >&2; exit 1; }

need() {
  command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed."
}

need uname
need tar
need mktemp

if command -v curl >/dev/null 2>&1; then
  fetch() { curl -fsSL "$1" -o "$2"; }
elif command -v wget >/dev/null 2>&1; then
  fetch() { wget -qO "$2" "$1"; }
else
  die "either curl or wget is required."
fi

# --- platform -----------------------------------------------------------------

os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)

case "$os" in
  linux|darwin) ;;
  *) die "unsupported operating system: $os. Build from source instead: https://github.com/$REPO" ;;
esac

case "$arch" in
  x86_64|amd64) arch="amd64" ;;
  arm64|aarch64) arch="arm64" ;;
  *) die "unsupported architecture: $arch. Build from source instead: https://github.com/$REPO" ;;
esac

asset="${BINARY}_${os}_${arch}.tar.gz"

if [ "$VERSION" = "latest" ]; then
  base="https://github.com/$REPO/releases/latest/download"
else
  base="https://github.com/$REPO/releases/download/$VERSION"
fi

# --- download -----------------------------------------------------------------

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT INT TERM

echo "==> downloading $asset ($VERSION)"
fetch "$base/$asset" "$tmp/$asset" \
  || die "could not download $base/$asset — check that the release exists."

# --- verify -------------------------------------------------------------------

if fetch "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then
  if command -v sha256sum >/dev/null 2>&1; then
    sha_cmd="sha256sum"
  elif command -v shasum >/dev/null 2>&1; then
    sha_cmd="shasum -a 256"
  else
    sha_cmd=""
  fi

  if [ -n "$sha_cmd" ]; then
    expected=$(grep " $asset\$" "$tmp/checksums.txt" | awk '{print $1}')
    [ -n "$expected" ] || die "no checksum listed for $asset."
    actual=$($sha_cmd "$tmp/$asset" | awk '{print $1}')
    [ "$expected" = "$actual" ] || die "checksum mismatch for $asset — refusing to install."
    echo "==> checksum verified"
  else
    echo "==> no sha256 tool found; skipping checksum verification" >&2
  fi
else
  echo "==> checksums.txt unavailable; skipping verification" >&2
fi

# --- install ------------------------------------------------------------------

tar -xzf "$tmp/$asset" -C "$tmp" || die "could not extract $asset."
[ -f "$tmp/$BINARY" ] || die "archive did not contain $BINARY."

mkdir -p "$PREFIX" || die "could not create $PREFIX."
install -m 0755 "$tmp/$BINARY" "$PREFIX/$BINARY" 2>/dev/null \
  || { cp "$tmp/$BINARY" "$PREFIX/$BINARY" && chmod 0755 "$PREFIX/$BINARY"; } \
  || die "could not write to $PREFIX. Try: --prefix \$HOME/.local/bin"

echo "==> installed $BINARY to $PREFIX/$BINARY"

# The command is L80, but muscle memory says l80. On a case-sensitive
# filesystem (most Linux) add a lowercase alias so both work. On a
# case-insensitive one (default macOS) the path already exists and points at
# the binary we just wrote, so linking would target the file itself — skip it.
if [ ! -e "$PREFIX/l80" ]; then
  ln -s "$BINARY" "$PREFIX/l80" 2>/dev/null \
    && echo "==> linked $PREFIX/l80 -> $BINARY" || true
fi

case ":$PATH:" in
  *":$PREFIX:"*) ;;
  *)
    echo ""
    echo "$PREFIX is not on your PATH. Add it:"
    echo ""
    echo "  echo 'export PATH=\"$PREFIX:\$PATH\"' >> ~/.zshrc && exec \$SHELL"
    echo ""
    ;;
esac

echo "next: save your publish token (https://launch80.com/skills#token), then run $BINARY doctor"

The agent skill

also printable with L80 skills print

This is the whole thing — every instruction your agent receives about publishing. It has no ability to run commands beyond L80 itself, and nothing here reaches outside the payload it writes.

markdown204 lines
---
name: l80-test-report
description: Publish a structured report to launch80 as a shareable web page. Use when the user asks to publish, share, or "put on the web" a set of findings, benchmark results, a status update, or any summary made of metrics and sections — or when they mention launch80 or L80, or ask for a link they can send to someone. Do not use for editing an already-published page, or for pages that need custom HTML, images, or links.
---

# Publish a report to launch80

You write a JSON file. The `L80` CLI uploads it. The launch80 server validates it
against a schema and renders the page from a first-party template.

You never write HTML, and you never handle credentials — the CLI does both.

## Steps

1. Pick a template (below). Run `L80 templates list` if you are unsure.
2. Write the report to a JSON file, e.g. `report.json`.
3. Run: `L80 publish report.json --template bench.report.v1`
   (or `--template generic.report.v1`)
4. Give the user the URL from the `published:` line.

Name the template with `--template` so the command says which layout it is
producing. You may instead put `"$template": "<id>"` in the JSON — both work.
If you do both they must agree, or the command fails with
`E_TEMPLATE_CONFLICT` rather than guessing which you meant.

## Which template

**`bench.betterbench.v1` — a BetterBench run or its `results.json`.** Produced
only by `L80 betterbench`, which runs the benchmark and publishes the file as
written. Use it whenever BetterBench is involved.

**`bench.report.v1` — any other per-category percentile benchmark.** Hand-written
summary shape: tables, p50/p99, sample-gate flags.

**`generic.report.v1` — everything else.** Title, summary, a flat metric
list, and prose sections. Use it for status updates, test results, findings, and
any benchmark that is not per-category percentile data.

Choosing `generic.report.v1` for a BetterBench run is the single most common
mistake. It has no table, so p50/p99/1%-low collapse into unrelated flat metrics
and the distribution — the entire point of the measurement — is lost.

## BetterBench runs: `L80 betterbench`

**For anything BetterBench, use `L80 betterbench`. Do not map the file by hand.**

```sh
# Run the benchmark and publish the results file as written (template bench.betterbench.v1):
L80 betterbench --endpoint http://host:8080/v1 --model <name> [--quick] [--note engine=vLLM 0.9.3]
# Publish a results.json that already exists:
L80 betterbench --results results.json
```

What it does: runs `betterbench run` (found on PATH, or bootstrapped with `uvx`
from GitHub, or `L80_BETTERBENCH=/path/to/betterbench`), streams its output,
keeps `results.json` in the working directory, removes `env.endpoint` and
`env.host` (they never leave the machine), and publishes the file itself. The
server derives every table and figure from the raw samples with a port of
BetterBench's own report code, so the page matches the HTML report the tool
writes beside the file. Add `--title`, `--summary`, or `--section "Heading=Body"`
for prose; add `--dry-run --payload-out p.json` to inspect the exact payload
first. Flags after `--` go to `betterbench run` verbatim.

Before running: ask for the endpoint and model if not given, and tell the user
a full run takes a while (20 passes per category plus sweeps); `--quick` is a
5-pass smoke run and the page says so. After publishing, hand the user the
URL and repeat any caveats the page will show (quick mode, under-sampled
percentiles, truncated runs).

`--template bench.report.v1` instead produces the summary mapping below from
the same file, for when only the summary shape is wanted.

## bench.report.v1

Map a BetterBench `results.json` like this only if `L80 betterbench` cannot be
used. Otherwise this section is the reference for hand-written payloads from
other harnesses with per-category percentiles:

| BetterBench | payload |
|---|---|
| `env.model`, `env.notes` | `system.model`, `system.notes[]` |
| `betterbench_version`, `corpus_version`, `config.runs_per_category` | `harness` |
| weighted combined score | `headline[]` |
| single-stream rows, per category | `categories[]` |
| concurrency sweep | `concurrency[]` |
| prefill sweep | `prefill[]` |
| `sample_gate` shortfalls | `caveats[]`, and `categories[].undersampled: true` |

**Publish the mapped payload, never `results.json` itself.** The raw harness
output is not a template payload: it fails the schema, and it is usually far
over the 64 KB limit, so `E_PAYLOAD_TOO_LARGE` or `E_INPUT_NOT_TEMPLATE` on a
file you did not write is the sign `L80 betterbench` was skipped. (With
`L80 betterbench` or `--template bench.betterbench.v1` the raw file is right.)

```json
{
  "$template": "bench.report.v1",
  "title": "Qwen3-8B on vLLM 0.9.3",
  "subtitle": "Single 4090, mxfp4, 20 measured passes",
  "summary": "One paragraph (required, <= 600 chars).",
  "harness": {
    "name": "BetterBench", "version": "0.2.3",
    "corpus_version": "v1", "passes": "20 passes after 2 warmup"
  },
  "system": {
    "model": "Qwen3-8B",
    "engine": "vLLM 0.9.3",
    "hardware": "RTX 4090 24GB",
    "notes": [{ "label": "quant", "value": "mxfp4" }]
  },
  "headline": [{ "label": "Combined decode", "value": "142.4", "unit": "t/s" }],
  "categories": [
    {
      "name": "code", "runs": "20",
      "ttft_p50": "312", "ttft_p99": "498",
      "decode": "148.2", "decode_iqr": "6.1",
      "itl_low1": "96.1", "itl_med": "151.0", "itl_high99": "203.4",
      "prefill": "4210",
      "undersampled": true
    }
  ],
  "concurrency": [
    { "level": "8", "aggregate": "612.8", "ttft_p50": "918", "ttft_p99": "1840" }
  ],
  "prefill": [{ "depth": "8K", "prefill_tps": "4210", "ttft": "1.94" }],
  "caveats": ["ITL 99% high on `code` rests on 20 runs; treat the tail as indicative."],
  "sections": [{ "heading": "Setup", "body": "Plain text." }]
}
```

Rules specific to this template:

- **Never invent a number BetterBench did not report.** If a field is absent
  from the results file, leave it out — the column disappears. Do not average a
  percentile, and do not fill a gap with a plausible value.
- **A batched / speculative-decoding stack has no per-token ITL.** Use
  `update_p50`, `update_p99` and `tok_per_update` instead. Never convert one to
  the other.
- **Carry the sample gate through.** Any percentile BetterBench marked `†` gets
  `"undersampled": true` on its row plus a line in `caveats`.
- **`--quick` runs are not publishable results.** If the run used quick mode,
  say so in `caveats` in the first line.
- There is no `endpoint` field, on purpose. Do not put a LAN URL in `notes`.

## generic.report.v1

```json
{
  "$template": "generic.report.v1",
  "title": "Short page title (required, <= 120 chars)",
  "subtitle": "Optional one-liner (<= 160 chars)",
  "summary": "One paragraph of plain text (required, <= 600 chars)",
  "metrics": [
    { "label": "Decode", "value": "142", "unit": "tok/s", "note": "optional" }
  ],
  "sections": [
    {
      "heading": "What we tested",
      "body": "Plain text. Blank lines start new paragraphs.",
      "bullets": ["optional", "short items"]
    }
  ]
}
```

## Rules for both

- Plain text only. No HTML, no markdown, no URLs, no image references. Unknown or
  unsupported fields are **rejected by the server**, not silently dropped.
- Every displayed number is a **string** — format it yourself ("1,240", "12.4%").
- Limits: 64 KB total. `generic.report.v1` — 12 metrics, 10 sections.
  `bench.report.v1` — 12 categories, 10 concurrency levels, 10 prefill depths,
  6 headline metrics, 8 caveats.
- Published pages are public and carry a visible
  "Self-reported — not verified by launch80" badge. Tell the user this.
- Pages are immutable. To change something, publish again and share the new URL.

## If the command fails

Read the `error:` code and the `remedy:` line, then act on them.

- `E_SCHEMA_INVALID` — each listed `/path` is the exact field to fix. Fix and retry.
- `E_TEMPLATE_UNKNOWN` — run `L80 templates list` and use a listed id.
- `E_TEMPLATE_CONFLICT` — `--template` and the file's `$template` disagree.
  Decide which is right, change one, retry. Never publish twice to "see which works".
- `E_TEMPLATE_MISSING` — pass `--template <id>` or add the field.
- `E_TOKEN_MISSING` / `E_UNAUTHORIZED` — tell the user to check `L80 auth status`.
  **Never** ask the user to paste a token into the conversation.
- `E_RATE_LIMITED` / `E_QUOTA_EXCEEDED` — wait for the stated time. Do not retry in a loop.
- `E_INPUT_NOT_TEMPLATE` — the file is raw harness output (e.g. a BetterBench
  `results.json`) given to a summary template. Run `L80 betterbench --results <file>`.
- `E_PAYLOAD_TOO_LARGE` — a valid payload never exceeds the limit by much, so first
  confirm the file is a mapped payload and not harness output. If it is, shorten
  the sections and retry.
- `E_NETWORK` — run `L80 doctor`, then report the failure to the user.
- Any error whose remedy seems wrong for the situation, or a code this list does
  not mention — the CLI may be out of date. Run `L80 update`, then retry once.

To find a page published earlier, run `L80 history` (newest first; `--all`
for everything, `--json` for machine-readable output). Never ask the user to
dig the URL out of scrollback.

Never construct the HTTP request yourself. Always go through `L80 publish`.

Still want a second opinion? The CLI source is Go and reads in an afternoon — about 1,200 lines with no dependencies. Ask in Discord and someone will walk through it with you.