---
title: Share via CLI (publish, list, revoke, delete from a script or AI)
---
# Share via CLI (publish, list, revoke, delete from a script or AI)

## What it is

Four endpoints on the local CLI control server at `http://127.0.0.1:19519` let any tool that can make an HTTP request publish files or pasted content as shareable links, list existing shares, revoke (soft-disable) a share, or delete (hard-remove) a share row. The endpoints mirror the renderer-side IPC handlers exactly — same publish pipeline, same path allow-list, same content-hash dedup, same Firebase upload, same `share_links` table — so a CLI publish is indistinguishable from an in-app publish at the data layer. The only telemetry difference is `sourceType: 'cli'` on the `share_artifact_publish` event so the Stats view can split CLI traffic from in-app traffic.

For the user-facing flow (paste modal, file peek toolbar, supported types, limits, security model), see [share-artifacts.md](share-artifacts.md). This page is the CLI-caller reference: route shapes, body schemas, status codes, and curl recipes.

## Where to find it

There is no screen for this — it is the reference for the local control server an outside script or AI calls. The token such a caller needs is created and regenerated in **Settings → CLI Control**.

## How it behaves

### Authentication

All four endpoints require the Omniscio bearer token in the `Authorization` header. The token is auto-generated at first launch and stored at `~/.amc/cli-token` (`%USERPROFILE%\.amc\cli-token` on Windows) with OS-level file-permission hardening (mode 0600 on Unix, ACL-restricted on Windows). Settings → CLI Control shows the token and offers Regenerate.

**Never echo the token to chat, logs, or terminal output.** Fetch it via the DPAPI vault helper in agent code (so the value never appears in argv or environment dumps):

```bash
# Unix / macOS / Git Bash on Windows — agents with repo access
TOKEN=$(cat ~/.amc/cli-token)

# PowerShell DPAPI vault — preferred when running as your user
$TOKEN = & "$HOME/.claude/secrets/get-secret.ps1" amc-cli
```

If a token leaks (curl -v in chat, accidental log line, screenshot), regenerate it immediately at Settings → CLI Control.

### Endpoints

### `POST /share/publish` — publish a file or pasted content

**Auth**: bearer token in `Authorization` header (query-string `?token=` is rejected, like every other mutation route). Body cap 1 MB at the dispatcher level.

**Rate limit — publishing has its OWN per-session budget (12/minute), NOT the shared mutation pool.** It used to sit on the global 10-mutations-per-minute bucket shared by every session and every mutating route, so another session's unrelated writes could refuse your publish after only two or three artifacts. It now has a dedicated per-session budget — your own publishes are the only thing that can exhaust it. `POST /report-template/render` shares that same budget (it performs the same publish). Revoke and delete still use the shared mutation pool.

A refused publish returns `429` with an honest `Retry-After` (seconds until a slot actually frees, not a flat 60) plus a `retryAfterSeconds` body field — **read one of those instead of guessing a sleep interval.** Some clients hide response headers: PowerShell 5.1's `Invoke-RestMethod` raises a terminating error on a 429 and discards them, so read the body field (or catch the exception and inspect `$_.Exception.Response`).

The budget is keyed on your session's PROVEN identity, so rotating the `X-AMC-Source-Session-Id` header does not grant a fresh one.

**Encoding — prefer `path` over `content`.** For a local file, send its **`path`**: Omniscio reads the bytes server-side as UTF-8, so a wrong-encoding read on the caller's side (e.g. PowerShell `Get-Content -Raw`, which defaults to Windows-1252 — not UTF-8) cannot garble multibyte characters (em-dashes, bullets, curly quotes, emoji). If you must send `content`, read **and** transmit it as UTF-8 (PowerShell: `[IO.File]::ReadAllText($p,[Text.Encoding]::UTF8)` plus a UTF-8 **byte** body). On suspected double-encoding the response adds a non-fatal `warning` (see below) — it never rewrites your content. Always verify the LIVE url, not the local file.

**Body schema** (JSON, exactly one of `path` / `content` required):

| field               | type                                           | required         | notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------------------- | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `path`              | string (1–2048 chars)                          | XOR with content | Absolute path to a file inside an active session's workdir, `~/Claude`, **the calling session's own scratch folder (`$AMC_SESSION_TMP` — send `X-AMC-Source-Session-Id` to claim it; it is that session's folder only, never another session's)**, a folder the user granted the app, or Omniscio's published-pastes dir. Symlinks are resolved before allow-listing. A refusal lists the exact roots it accepted in `allowedRoots` An H.264 MP4 (`.mp4` / `.m4v`) publishes as a **video** — see [Videos and live films](#videos-and-live-films) |
| `content`           | string (1–2 MB)                                | XOR with path    | Inline UTF-8 content to publish — staged under `<userData>/published-pastes/<uuid>.<ext>` before going through the pipeline                                                                                                                                                                                                                                                                                                                                                                                              |
| `contentType`       | `'html'` / `'markdown'` / `'text'` / `'react'` | optional         | Defaults to `text`. Drives the staging-file extension and the share-page renderer. `'markdown'` (or a `path` ending in `.md`) renders through Omniscio's shared markdown renderer — full GFM incl. tables, task lists, footnotes and highlighted code, HTML comments dropped, raw HTML sanitized — so **never pre-convert markdown to HTML yourself**. `'react'` stages a `.tsx` and runs through the React/JSX runtime (same as the paste modal's **React** choice) — so you can publish a live React component from inline `content`, not only from a `.tsx` file by `path`                                                                                                                                                                                                                |
| `label`             | string (≤ 200 chars)                           | optional         | Display label in the Settings → Sharing list. Defaults to the source filename when omitted                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `expiresIn`         | int seconds (60 to 60 × 60 × 24 × 365)         | optional         | Defaults to 30 days (`2592000`) when omitted. Pass `3600` for 1h, `86400` for 1d, `604800` for 7d, `2592000` for 30d                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `title`             | string (≤ 200 chars)                           | optional         | Browser-tab title + link-preview (OG/unfurl) title. Also the visible page `<h1>` on a **framed** share (markdown / text / code / image) — but a full-bleed `html` / `react` share (the default) has no chrome, so there `title` sets only the tab + unfurl card, never a visible on-page heading. `label` stays the smaller subtitle. Defaults to the source filename                                                                                                                                                    |
| `fullBleed`         | boolean                                        | optional         | Chromeless, viewport-filling render — no 960px frame, no heading, no footer. Honored for `html` / `react` / `image` only; ignored (framed) for every other kind. **Defaults ON for `html` / `react`** (a self-contained page owns the whole surface), OFF for everything else — pass `fullBleed:false` to force the framed reading view on an html/react share                                                                                                                                                           |
| `background`        | string (≤ 64 chars)                            | optional         | Full-bleed backdrop colour — **hex (`#rgb`/`#rrggbb`/`#rrggbbaa`) or `rgb()`/`rgba()` only**; anything else is dropped for an automatic backdrop (a blurred copy of the image for images, a theme-aware neutral for html/react). Read only when `fullBleed` is honored for the kind                                                                                                                                                                                                                                      |
| `commentsEnabled`   | boolean                                        | optional         | Override the global comments default for this share. `true` enables the comments sidebar, `false` disables it regardless of the user's `shareCommentsEnabled` / `shareCommentsDefaultOn` settings. When omitted the share inherits the global default                                                                                                                                                                                                                                                                    |
| `teamChatChannelId` | string (≤ 100 chars)                           | optional         | Link this share to a Team Chat channel so the Cloud Function cross-posts new comments as thread replies. The channel must already exist. Persisted on the DB row + mirrored to Firestore; only applied to new publishes (ignored on dedup hits)                                                                                                                                                                                                                                                                          |
| `updateToken`       | string (1–256 chars)                           | optional         | **In-place update** — refresh the artifact share with THIS token (same public URL, new content) instead of minting a fresh link. The target must be a live (non-revoked, non-expired) artifact share; a thread / digest / unknown token is a `400 No such active share to update`. Its existing **expiry is preserved** (`expiresIn` is ignored on an update). The response returns the SAME `url` with `updatedInPlace: true`; on an old-relay fallback it mints a new link + retires the old (`updatedInPlace: false`) |

**Success response (200)**:

```json
{
  "ok": true,
  "data": {
    "token": "a1b2c3d4e5f6a7b8",
    "url": "https://shares.omniscio.com/s/a1b2c3d4e5f6a7b8",
    "publicUrl": "https://shares.omniscio.com/s/a1b2c3d4e5f6a7b8",
    "artifactType": "image",
    "reused": false,
    "contentHash": "9f2b…",
    "sizeBytes": 22698
  }
}
```

`reused: true` means the SHA-256 content hash matched an existing active share; no second upload happened and the existing `token` / `url` were returned. `artifactType` is one of `'html'`, `'markdown'`, `'svg'`, `'image'`, `'pdf'`, `'text'`, `'code'`. `updatedInPlace` appears **only** when you passed `updateToken`: `true` = the SAME link now serves your new content; `false` = the relay could not reuse the token, so a fresh link was minted and the old one retired (read `url` for the new link).

`contentHash` + `sizeBytes` describe the blob this call actually STORED, and match what `GET /share/list` reports for the token. They are the way to **verify an in-place update landed**: `ok: true` tells you the call succeeded, not that the bytes you sent are the ones now being served — so on an `updateToken` publish, compare `sizeBytes` against your source and treat a mismatch as a failed refresh. Both are absent on a `reused: true` dedup hit, where nothing new was written.

When the user's **Open shares in app** setting (`sharesOpenInApp`) is ON, `data.url` is swapped to a deep-link URL (`omniscio://share/<token>`) that opens the share in Omniscio's built-in viewer instead of the browser. `data.publicUrl` always contains the HTTP URL regardless of the setting. Callers that always want the browser URL should read `publicUrl`; callers that want the user's preferred link should read `url`.

When the user has also enabled **Show new shares automatically** (`sharesAutoOpenOnCreate`, opt-in default OFF, sub-toggle of `sharesOpenInApp`), a successful publish additionally raises a one-click **"Go there"** toast on the user's screen via the agent-foreground nav-suggestion path (`suggestAgentNavigation()`). The toast is never a forced view-switch — if the user is in another app it becomes a durable inbox card instead. The toast carries only the share token so an arbitrary URL can never be coerced into the viewer. Toasts coalesce per source-session (a looping agent bumps one toast, latest label wins). This side-effect is transparent to callers: the publish response shape is unchanged.

A non-fatal **`warning`** string may appear alongside `data` when you publish via `content` and the text looks **double-encoded (mojibake)** — e.g. an em-dash arriving as the three chars `â€"` because the file was read in the wrong code page before upload. The share still publishes; the warning is advisory. The fix is to re-publish by sending the file **`path`** (read as UTF-8) instead of `content`. The server never auto-repairs the content (that would corrupt text that legitimately contains those characters). Genuinely-invalid UTF-8 is still rejected with a `400` earlier in the pipeline. Only `content` is scanned — a `path` publish never carries this warning.

**The same `warning` also carries what a render of your published page found.** After the artifact is live, Omniscio loads it once offscreen at phone size and reports what it saw. It is advisory only — a finding never refuses, delays, or changes a publish, and a check that cannot run stays silent rather than claiming the page is fine. Three things get reported:

| Finding | What it means | What to do |
| --- | --- | --- |
| `N of M blocks did not reach the rendered page` | A branded report's own prose is missing from the page a reader loads — usually a block written in a shape no style dispatches on. Two published reports lost **100% of their body** this way and still returned success. | Write blocks as `{"p": "…"}`; fix the shapes and re-publish with `updateToken`. |
| `this artifact's own script threw while rendering` | Your page's JavaScript raised an uncaught error, so the reader sees only whatever had been drawn before it stopped. | Fix the script and re-publish with `updateToken`. |
| `the page is Npx wider than a 390px phone` | Content sits off the right edge, inside a frame a reader cannot scroll sideways. | Let the layout wrap instead of fixing a width. |

There is deliberately **no "this page looks empty" warning**. Judging emptiness by counting visible text was measured against every published artifact and was wrong every time it fired — a video, an animation and a design mockup all legitimately carry a few words — while missing the report that had actually lost its body.

**Error responses**:

| status | error string                     | cause                                                                                                  |
| ------ | -------------------------------- | ------------------------------------------------------------------------------------------------------ |
| 400    | `outside-allowlist`              | Path doesn't resolve into an active workdir, `~/Claude`, this session's scratch folder, a granted folder, or published-pastes — the body's `allowedRoots` is the exact list that was accepted |
| 400    | `unsupported-type`               | File extension not in the supported list (Office, archives, binaries); or a video that is not an H.264 MP4 (the body then carries a `hint` to convert it through `POST /convert`) |
| 400    | `magic-byte-mismatch`            | Extension says one thing, the file's first bytes say another                                           |
| 400    | `empty-file`                     | Zero-byte source                                                                                       |
| 400    | `No such active share to update` | `updateToken` doesn't resolve to a live artifact share (revoked / expired / digest / thread / unknown) |
| 400    | `A video share cannot be updated in place…` | `updateToken` aimed at a video share, `updateToken` with a video `path`, or a republish of a video share — publish the new video as a new share |
| 400    | `A custom link name is not available for a video share.` | `customSlug` sent with a video `path` |
| 400    | _(zod parse error)_              | Body validation failed — schema fields out of range, both `path` and `content` set, etc.               |
| 401    | `unauthorized`                   | Missing or invalid bearer token                                                                        |
| 413    | `size-cap`                       | Source exceeds the type's raw-bytes cap (22 MB image, 18 MB PDF) or rendered HTML exceeds 25 MB        |
| 429    | `Rate limit exceeded (12 artifact publishes/minute for one session)` | YOUR session published 12 artifacts in the past minute. Carries `Retry-After` + a `retryAfterSeconds` body field — wait that long rather than guessing. Another session's traffic can no longer cause this. |
| 502    | `firebase-upload-failed`         | Generic upload error — check Settings → Sharing → Firebase config and Omniscio's `main.log`            |
| 507    | `firebase-quota`                 | Spark-plan free-tier exceeded — prune old shares or upgrade to Blaze                                   |
| 504    | `Publishing is taking too long — please try again` | The upload ran out of time — about 45 s for a page, about 4 minutes for a video. Nothing was published |

**Recipes**:

Publish a file by path:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path":"C:\\Software Projects\\my-project\\screenshot.png","label":"Bug repro"}' \
  http://127.0.0.1:19519/share/publish
```

Publish inline Markdown with 24-hour expiration:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "content":"# Title\n\nSome **markdown**.",
    "contentType":"markdown",
    "label":"Notes",
    "expiresIn":86400
  }' \
  http://127.0.0.1:19519/share/publish
```

Publish inline HTML that never expires:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content":"<h1>Hello</h1>","contentType":"html","label":"Greeting"}' \
  http://127.0.0.1:19519/share/publish
```

Publish a full-screen HTML dashboard with a custom title (no Omniscio frame). Note that `html` / `react` are full-screen by default now, so the `"fullBleed":true` below is explicit-but-redundant — kept here to show the field; pass `"fullBleed":false` instead when you want the framed reading view:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "content":"<div style=\"display:flex;height:100vh;align-items:center;justify-content:center\">Hi</div>",
    "contentType":"html",
    "title":"Live Dashboard",
    "fullBleed":true,
    "background":"#0b0e14"
  }' \
  http://127.0.0.1:19519/share/publish
```

Publish with comments enabled and linked to a Team Chat channel:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "path":"/path/to/report.md",
    "label":"Weekly report",
    "commentsEnabled":true,
    "teamChatChannelId":"channel-abc123"
  }' \
  http://127.0.0.1:19519/share/publish
```

Update an existing share's content **in place** (same URL) — regenerate the file, then point the share's own token at the fresh bytes:

```bash
TOKEN=$(cat ~/.amc/cli-token)
# ...regenerate C:\...\report.html with new content at the SAME path...
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path":"C:\\...\\report.html","contentType":"html","updateToken":"a1b2c3d4e5f6a7b8"}' \
  http://127.0.0.1:19519/share/publish
# → { "ok": true, "data": { "url": "https://.../s/a1b2c3d4e5f6a7b8", "updatedInPlace": true, ... } }
```

#### Videos and live films

- **A video publishes by `path`** — pass an **H.264 MP4** (`.mp4` / `.m4v`). It skips the page builder and goes to the one video publisher (the same one screen recordings use), after the same folder allow-list check as any file. The reply has the usual shape with `artifactType: "video"`, and `data.publicUrl` opens a share page that plays it in the [one video player](video-player.md). `shortLink: true` adds a short link, `expiresIn` works as for a page, and a retry carrying the same `X-Client-Request-Id` returns the first share. The page it opens on is labelled "Shared video", and its Download control is the download icon in the player's bottom bar. Each publish makes a new share: a video is never de-duplicated by content, so publishing the same file twice makes two links.
- **Refused before anything uploads** (`400 unsupported-type` with a `hint`): another video format (`.mov`, `.webm`, `.mkv`, `.avi`, …), a file named `.mp4` whose first bytes are not an MP4, or an MP4 whose video ffprobe reports as something other than H.264 (the one format every browser plays). Convert it with `POST /convert` (`{ "inputPath": "<file>", "to": "mp4" }`) and publish the MP4 that returns. Without ffprobe installed the MP4 publishes anyway, with a `warning`.
- **Page-only fields** (`fullBleed`, `background`, `commentsEnabled`, `teamChatChannelId`) are ignored for a video, with a `warning`; `customSlug` is refused.
- **A video share is never replaced in place** — a video and a page are stored differently, so `updateToken` (in either direction) and `POST /share/:token/republish` on a video share are refused with `400` before anything uploads. Publish the new video as a new share.
- **A large video takes a while** — the route allows about 4 minutes for the upload (a page stops at about 45 s) and answers `504` if it runs out, with nothing published.
- **A film drawn live in code** (canvas frames, an optional soundtrack) plays through the same player: fetch the player's script from `GET /video-player/script` (raw JavaScript, any session token), put it in a `<script>` tag in your HTML page, mount the film, and publish the page by `path`. Never build your own video controls.

Publish an MP4 with a short link:

```bash
curl -X POST \
  -H "Authorization: Bearer $AMC_CLI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path":"C:\\Users\\me\\Claude\\launch-film.mp4","label":"Launch film","shortLink":true}' \
  http://127.0.0.1:19519/share/publish
```

A film drawn in code — the page carries the player script, then mounts the film:

```html
<div id="film"><canvas id="frame" width="1280" height="720"></canvas></div>
<script>/* the text GET /video-player/script returned */</script>
<script>
  const canvas = document.getElementById('frame')
  const ctx = canvas.getContext('2d')
  const media = OmniscioVideoPlayer.createTimelineMedia({
    duration: 20, // seconds
    render: (t) => { /* draw the frame for time t (seconds) onto ctx */ }
    // audio: an <audio> element or URL — when given, it keeps time and pitch holds at any speed
  })
  OmniscioVideoPlayer.mount(document.getElementById('film'), { media, surface: canvas })
</script>
```

### `GET /share/list` — list every share row

**Auth**: bearer token in `Authorization` header. **Not** rate-limited (read-only) but still bearer-gated because share rows expose public content URLs.

**Query string**:

| param            | values         | notes                                                |
| ---------------- | -------------- | ---------------------------------------------------- |
| `includeRevoked` | `true` / unset | When `true`, revoked rows are included in the result |

**Success response (200)**:

```json
{
  "ok": true,
  "data": {
    "shares": [
      {
        "id": 42,
        "token": "a1b2c3d4e5f6a7b8",
        "url": "https://shares.omniscio.com/s/a1b2c3d4e5f6a7b8",
        "label": "Bug repro",
        "scope": "conversation",
        "artifactKind": "image",
        "status": "published",
        "revoked": false,
        "createdAt": "2026-05-14T12:34:56.000Z",
        "expiresAt": null,
        "...": "..."
      }
    ]
  }
}
```

Field set matches `share_links` schema — see [src/main/db/queries-share.ts](../../src/main/db/queries-share.ts) for the exact column list. The same row shape is returned by the renderer-side `SHARE_LIST` IPC handler.

**Recipe**:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:19519/share/list?includeRevoked=true"
```

### `POST /share/:token/revoke` — soft-disable a share

**Auth**: bearer token, 10 mutations/minute rate limit.

Marks the row `revoked = 1` (keeps the DB row + the blob in Firebase storage but invalidates the URL — viewers see a 410-style "this share has been revoked" page). Use this for shares you might want to audit later or un-revoke (DB-only operation, no Firebase calls).

**Success response (200)**:

```json
{ "ok": true }
```

**Error responses**: 401 (no token), 404 (`Share not found` — the path token doesn't match any row), 429 (rate limit).

**Recipe**:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -X POST -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/share/a1b2c3d4e5f6a7b8/revoke
```

### `DELETE /share/:token` — hard-delete a share row

**Auth**: bearer token, 10 mutations/minute rate limit.

Deletes the DB row AND removes the Firebase blob. The URL returns 410 Gone afterwards. Irreversible.

**Success response (200)**:

```json
{ "ok": true }
```

**Error responses**: 401 (no token), 404 (`Share not found`), 429 (rate limit).

**Recipe**:

```bash
TOKEN=$(cat ~/.amc/cli-token)
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/share/a1b2c3d4e5f6a7b8
```

### Behavioural notes

- **Dedup is global AND presentation-aware** — `findActiveByContentHash()` matches against every active (non-revoked, non-expired) row regardless of who published it (in-app, CLI, IPC), so a CLI publish can reuse a token first generated by the paste modal and vice-versa. The dedup key folds the raw bytes **plus** the presentation knobs (`title` / `fullBleed` / `background`) together, so re-publishing identical bytes with a **different title / layout / colour** mints a fresh link instead of returning the old presentation; identical bytes **and** identical presentation reuse the same token.
- **`sourceType` is not configurable from the body**. The CLI route hard-codes `sourceType: 'cli'` server-side so the telemetry origin cannot be spoofed.
- **No inbox approval gate** — publish is apply-immediately. The design rationale is that publish doesn't spawn Claude (no real-money cost from approval bypass) and the renderer-side flow is also apply-immediately, so gating only the CLI would be asymmetric. Firebase storage costs are real but bounded by the shared 10-mutations/min rate limit.
- **Retries: send `X-Client-Request-Id`.** A retry carrying the same id as a finished publish returns that share instead of minting another — for a page and a video alike. For a page, content-hash dedup also returns the same token for the same bytes; a video is never de-duplicated by content, so the same file published twice makes two shares.
- **The 1 MB body cap is dispatcher-level**, so any `content` over ~1 MB will be rejected at the parse layer even though the Zod schema allows up to 2 MB. For >1 MB inline content, stage the bytes to a file first and publish by `path`.
- **Build a scrollable document, not a full-viewport app shell (the #1 mobile-render failure).** A share renders inside a sandboxed iframe whose height is **not** the phone's visible viewport (mobile browser chrome + the sandbox wrapper eat vertical space), so an artifact locked to `100vh`/`100dvh` with `overflow:hidden` and a bottom-pinned bar (a composer, toolbar, or footer) clips that bar **off-screen and unreachable** — the user cannot scroll or zoom to it. Author every published artifact as natural top-to-bottom flow: no `100vh`/`100dvh` height lock on `html`/`body`, no `overflow:hidden` page root, no flex-stretch that fills the screen, no bottom-pinned regions (reserve `position:fixed` for genuine overlays). This is **independent of `fullBleed`** — a full-bleed page still scrolls fine as long as its own document flows. **Verify before you hand over the link**: open the live `data.url` at a short mobile height (~390×600) and confirm the key content (especially any input box or button) is visible without scrolling past the fold.
- **CLI publishes are unprotected by default.** The body schema does NOT accept `passwordHash` / `viewCap` / `notifyOnView` fields — every CLI-published share starts with no password gate, no view cap, view count `0`, and `notify_on_view = false`. To add a password after publishing, either open the resulting share in the in-app **Shares** view and use the Edit modal (UX detailed in [shares-view.md § Editing a share](shares-view.md)), or call `POST /share/:token/protection` with a `{ "password": "<plaintext>" }` body — the endpoint derives the hash server-side via `deriveSharePasswordHash()` (so no crypto has to be duplicated in shell callers) and re-mirrors the credential immediately; pass `{ "password": null }` to clear it. The in-app Edit modal still hashes in the renderer via SubtleCrypto; the CLI route instead accepts the plaintext over the loopback, bearer-gated connection and never persists it. This route sets or clears the password only — view cap and view-notify stay in-app.

### Security model

Same as the in-app flow — see [share-artifacts.md § Security model](share-artifacts.md#security-model). The CLI surface adds two specifically-CLI considerations:

- **Localhost-only**: the CLI control server binds to `127.0.0.1:19519` and is not reachable from other machines. A misconfigured router cannot expose it.
- **Bearer token treatment**: the token is the credential for every mutation. Never include it in `curl -v` output (which dumps headers to terminal), never paste it into chat, never commit it to a repo. Rotate at Settings → CLI Control → Regenerate if any of those happen.

## For agents

### Auto-publish from Omniscio-spawned agents

These endpoints aren't only for external scripts — they're how **every agent Omniscio spawns** surfaces a visual result in your Shares panel, with no per-machine setup. Omniscio folds a publish instruction into the system prompt of every session it starts: when an agent produces a self-contained HTML artifact you should look at (report, chart, dashboard, slide deck, diagram), it `POST`s it to `/share/publish` so it lands in **Omniscio → Shares** and opens on any device.

The agent authenticates with the CLI control token, which Omniscio hands to the spawn directly as the `AMC_CLI_TOKEN` environment variable (falling back to `~/.amc/cli-token`). Because the token arrives in the env, the publish works even if the on-disk token file is missing. The agent writes the artifact to a file in its working directory (already on the publish allow-list) and passes its `path`: Omniscio reads that file as UTF-8 itself, so a wrong-encoding read on the agent's side cannot garble the output (dashes, bullets, curly quotes, emoji). Inline `content` is only a fallback for when no file can be written, and the server flags a non-fatal mojibake `warning` if that content looks double-encoded. The agent gives you the resulting `data.url` and is told to prefer this over emailing the file or any external upload script.

The same instruction tells an agent that a video publishes by its H.264 `.mp4` path, and that a film it draws in code plays through the one video player via `GET /video-player/script` — never through controls of its own.

The upshot: anything an agent shares shows up in one place — the Shares view — where you can preview, rename, set an expiry/password, or revoke it. The wiring (the `SHARE_PUBLISH_SHIM` system-prompt fragment + the `AMC_CLI_TOKEN` spawn-env hand-off) is specified in [backend-spawn-contract.md § 8](../../.claude/memory/contracts/backend-spawn-contract.md).

## Related

- [share-artifacts.md](share-artifacts.md) — the in-app flow with the same publish pipeline (paste modal + file-peek toolbar)
- [cli-control.md](cli-control.md) — the CLI control server's design, auth model, and full route catalogue
- [artifact-sharing.md](artifact-sharing.md) — the sibling feature for sessions / messages / selections (different inputs, same `share_links` table)
