# Seer — publishing HTML bundles as an agent

Seer is a personal preview host for self-contained HTML bundles. You (an AI agent)
zip up a page you built, `PUT` it here with a bearer token, and Seer returns a stable,
versioned URL a human can open in a browser. Re-uploading the same slug creates a new
version and live-reloads any viewer that already has the page open. This is the place
to put richer output than a chat reply can carry — dashboards, small apps, interactive
reports — instead of pasting a wall of code.

You need two things, which the human has given you (typically as environment
variables): the base URL of this Seer instance (`https://seer.build`) and an API key
(referred to below as `$API_TOKEN`). Seer keys look like `seer_sk_…`; a human mints
one from a workspace's settings page (`https://seer.build/settings/<workspace>`), where it is
shown exactly once. The key belongs to one workspace, so every bundle you upload
with it lands in that workspace — you never name the workspace yourself. A person
who belongs to several workspaces therefore holds one key per workspace: which key
you send is which workspace you publish to. Keep the key secret; it is the only
write credential.

## 1. Build the zip

- The zip must contain a root `index.html` (at the top level of the archive, not
  inside a subdirectory). That is what loads at the bundle URL.
- Use **relative** asset paths (`./style.css`, `assets/app.js`, `img/logo.png`).
  Absolute paths like `/style.css` will not resolve, because the bundle is served
  under `/<workspace>/b/<slug>/`.
- Nested directories are fine. Directory requests fall back to their `index.html`.
- Prefer a self-contained bundle (inline or bundled JS/CSS, or assets shipped inside
  the zip). External network requests are the human's browser's problem, not Seer's.
- Zip the **contents** of your build directory, not the directory itself, so
  `index.html` lands at the root of the archive:

```sh
# from inside the build directory:
zip -r ../bundle.zip .
```

Default size limit is 50 MB. Unsafe zip entries (absolute paths, `..`, null bytes)
are rejected.

## 2. Upload it

Pick a slug matching `[a-z0-9][a-z0-9-]{0,63}` (lowercase letters, digits, hyphens;
must start with a letter or digit; up to 64 characters). Send the zip as the raw
request body with `--data-binary` (not multipart):

```sh
curl -X PUT --data-binary @bundle.zip \
  -H "Authorization: Bearer $API_TOKEN" \
  https://seer.build/api/bundles/<slug>
```

`PUT` and `POST` behave identically. Each successful call creates the next version
for that slug.

## 3. Read the response

A successful upload returns `200` with JSON:

```json
{
  "slug": "<slug>",
  "version": 1,
  "workspace": "ws_…",
  "url": "https://seer.build/ws_…/b/<slug>/",
  "versionUrl": "https://seer.build/ws_…/b/<slug>/v/1/",
  "bytes": 2048,
  "files": 3,
  "hasIndexHtml": true
}
```

- `workspace` is the `ws_…` id your key belongs to; every URL in the response is
  scoped under it. You do not choose it — the key does.
- `url` is the **latest** URL: it always shows the newest version and live-reloads.
  Hand this one to the human in most cases.
- `versionUrl` is a **pinned** URL for this exact version; it never changes and does
  not live-reload. Use it when you want to reference a specific build permanently.
- Check `hasIndexHtml`: if it is `false`, you forgot the root `index.html` and the
  bundle URL will 404. Re-zip and re-upload.

Error responses are JSON with an `error` field. Notable statuses: `400` (invalid
slug, empty body, or bad zip), `401` (invalid, revoked, or missing key), `413` (zip
exceeds the size limit).

## 4. Iterating

Upload the same slug again to publish a new version. Any browser tab already open on
the latest `url` reloads itself automatically. You do not need to send a new link —
the old one keeps working and updates in place.

## 5. Listing what is published

```sh
curl -H "Authorization: Bearer $API_TOKEN" https://seer.build/api/bundles
```

Returns every bundle in your key's workspace with its full version history (slugs,
versions, sizes, timestamps), each tagged with its `workspace` id.

## 6. Images (screenshots in GitHub PRs, and anywhere else)

Besides zipped bundles, Seer hosts single **image files** — the main use is giving a
screenshot a URL you can embed in a GitHub PR, issue, or README, which is otherwise
hard to do programmatically. Upload the raw image bytes (no zip, no multipart) to
`/api/images/<filename>`:

```sh
curl -X PUT --data-binary @screenshot.png \
  -H "Authorization: Bearer $API_TOKEN" \
  https://seer.build/api/images/screenshot.png
```

The filename must match `[a-z0-9][a-z0-9._-]*` (max 64 chars) and end in `.png`,
`.jpg`, `.jpeg`, `.gif`, `.webp`, `.avif`, or `.svg` — the extension names the
format, and the body is sanity-checked against it.

Seer compresses on upload: orientation is baked in, the longest edge is capped at
2000px, metadata (EXIF, GPS) is stripped, and the image is re-encoded as WebP
(animated GIFs stay animated). When the WebP is no smaller than your original —
tiny icons, already-tight AVIF — the image is re-encoded in its own format
instead, with the same cap and metadata strip. SVGs are the one passthrough,
stored as-is. The response tells you what was stored — note the filename (and
URL) may end `.webp` even though you uploaded a `.png`:

```json
{
  "id": "img_…",
  "filename": "screenshot.webp",
  "workspace": "ws_…",
  "url": "https://seer.build/ws_…/i/img_…/screenshot.webp",
  "markdown": "![screenshot](https://seer.build/ws_…/i/img_…/screenshot.webp)",
  "bytes": 48213,
  "originalBytes": 231970,
  "contentType": "image/webp"
}
```

Paste `markdown` straight into a PR body. Every upload mints a fresh random
`img_…` id, so images are immutable — re-uploading the same filename gives a new
URL, and old URLs keep serving their bytes forever (with long-lived caching).
`GET /api/images` lists your key's workspace's images.

Image visibility follows the workspace, like bundles — with one exception:
**GitHub's image proxy (camo) is always served**, so an image embedded in a PR
renders for everyone who can see the PR even when its workspace is private. The
image URL itself contains an unguessable random id, so treat it like a capability
link: private means "only people who have the URL", not "only members".

## Sharing and viewing

Whether a bundle link is openable without signing in depends on its workspace's
visibility. A **public** workspace (the default) serves bundle URLs
(`/<workspace>/b/<slug>/`) to anyone with the link — no sign-in. A **private**
workspace serves them only to signed-in members; everyone else gets a generic
Seer 404 that reveals nothing, so a private bundle's title never leaks. The human
sets visibility per workspace on its settings page.

### Share links, which you can mint yourself

There is a third option: a **share link**, one revocable URL that opens one bundle for
someone with no account at all. It is how a bundle in a private workspace reaches an
outsider — a reviewer on a pull request, a client, anyone you cannot add to the
workspace. Your API key mints one, so you do not have to ask a human to do it:

```sh
curl -X PUT --data-binary @bundle.zip \
  -H "Authorization: Bearer $API_TOKEN" \
  https://seer.build/api/bundles/<slug>

curl -X POST https://seer.build/api/shares \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "content-type: application/json" \
  -d '{"kind":"bundle","target":"<slug>","label":"why this link exists"}'
```

```json
{ "id": "shr_…", "kind": "bundle", "target": "<slug>",
  "token": "seer_sh_…", "url": "https://seer.build/s/seer_sh_…", "expiresAt": null }
```

- Give the human (or paste into the pull request) the **`url`**. That is the thing that
  opens; the bare token is not a URL anyone can use.
- You do not name a workspace — your key already belongs to one, and naming a different
  one is refused.
- `label` is free text saying why the link exists ("for the PR", "for Anna"). It is what
  a member sees in the workspace's list of live links, and how they decide what to
  revoke. Write a useful one.
- `expiresAt` is optional (epoch ms or ISO 8601). Default is no expiry, because a
  revocable link that surprises its holder by dying is worse than one somebody forgets.
- **The `url` is shown exactly once.** Only its hash is stored, so it cannot be looked
  up later. If you lose it, mint another.
- `GET https://seer.build/api/shares` lists your workspace's live links (never their tokens);
  `DELETE https://seer.build/api/shares/<id>` revokes one.

Do not mint a share link when the plain `url` would do. A public workspace's bundle URL
already opens for anyone, and a share is a second secret to keep track of. Mint one when
the workspace is private, or when you want a link that can be taken back.

Treat the `url` like a password you are allowed to hand over: it is the whole of the
authorisation, so it belongs in the place the recipient will read it and nowhere public.
Seer keeps it out of `Referer` headers and out of search engines, but it cannot help
you if you paste it into a public issue.

Hand the `url` to whoever should see it, or open it yourself. You can also fetch it
back to verify the rendered page: a GET on the bundle URL returns the served
`index.html` (the latest URL has the live-reload script injected before `</body>`).

When a bundle link is shared in chat, Seer injects OpenGraph tags into the served
HTML so the link unfurls with your page's `<title>`, a description, and a Seer
card image. If you want full control of the preview, ship your own `og:` meta
tags in `index.html` — Seer leaves pages that declare any `og:` or `twitter:`
meta untouched.

The write side and the inventory are always private: uploading needs your API key,
and the lists (`GET /api/bundles`, `GET /api/images`, scoped to your key's
workspace) need it too. Public bundle and image links are the only thing viewable
without a credential.
