---
title: Share Artifacts (publish local files and pasted content as links) (part 2)
---
# Share Artifacts (publish local files and pasted content as links) (part 2)

## What it is

This is part 2 of the [Share Artifacts (publish local files and pasted content as links)](share-artifacts.md) page. It carries the next stretch of the material on that page, moved here because a single page is capped at 40,000 characters.

## Where to find it

Reach this part through [Share Artifacts (publish local files and pasted content as links)](share-artifacts.md) — it lists every part and explains where the feature lives in the product. Everything below is reached from the same place.

## How it behaves

Everything below is the behaviour, detail and edge cases that belong to this stretch of the Share Artifacts (publish local files and pasted content as links) page.

### Update in place (keep the URL)

The opposite of "Republish as new": keep the **same** public link but swap in **new content**. Use it when you regenerated a report at the same path, or want an already-shared link to reflect an edit without re-sending it — viewers of the old URL automatically see the new version. It reuses the share's existing token via the service's `republishToken` → `updatedInPlace` path (the same in-place mechanism KMS/Vault page-publishing uses, generalized to any artifact share).

- **In the app:** an **Update** action on a LIVE, file-backed artifact share in the [Shares view](shares-view.md) detail pane re-reads the share's source file and refreshes the same URL (behind a confirm, since it overwrites what the link serves).
- **From a script / agent:** pass `updateToken: <the share's token>` on `POST /share/publish` alongside your new `path` / `content` — see [share-cli.md](share-cli.md). The response returns the SAME `url` with `updatedInPlace: true`.
- **Guardrails:** the target must be a live (non-revoked, non-expired) artifact share — a thread / digest / unknown token is refused (`No such active share to update`). The link's existing **expiry is preserved** (a content update never changes the lifetime; use the Edit form / `SHARE_UPDATE` to change expiry). If the cloud relay is ever too old to reuse the token, it falls back to minting a fresh link and retiring the old one — so you never end up with two live copies — and the response's `updatedInPlace: false` tells you that happened.

- **Verify it landed.** The response carries `contentHash` + `sizeBytes` for the blob actually stored (matching what `GET /share/list` reports). `ok: true` means the call succeeded, not that your bytes are the ones now served — so on an update, compare `sizeBytes` with your source rather than trusting the status. If a content object cannot be written the publish now FAILS (`firebase-upload-failed`) instead of reporting success over stale bytes: for an html / react share the artifact is the separate `run.html` object, and refreshing only the shell would leave the previous page serving.

- **An update also re-points the share at the file it published.** Update a share from a DIFFERENT file and the share now remembers THAT file, so a later **Update** (or a Republish) re-publishes the newest file — not the one the link was first created from.

Full invariant set (validate-the-target, preserve-expiry, retire-on-fallback, replace-the-bytes-or-fail, refresh-hash-and-size-together, repoint-the-source, never-tear-down-the-existing-share, additive/opt-in): [share-artifacts-invariants-part2-contract.md § `update-token-refreshes-in-place`](../../.claude/memory/contracts/share-artifacts-invariants-part2-contract.md).

### Unfurl previews (OpenGraph + Twitter cards)

Every published share emits `<meta property="og:title">`, `og:description`, `og:type`, `og:url`, `og:image`, plus `twitter:card="summary_large_image"` inside the HTML shell. The title is the share's title (falling back to `label`, then the filename); the description is the label truncated to ~200 chars; the image is a per-share 1200×630 PNG uploaded as a sibling blob and served at `/s/<token>.png` (a static, fast path — the Cloud Function never has to render at request time).

**The card.** One branded design for every kind, because an unfurl is Omniscio presenting itself outside the product: an obsidian plate lit by the aurora glow, the deep-fill gradient hairline across the top, the **orb + Omniscio wordmark** top-left, a plain-English kind badge top-right (`WEB PAGE`, `DOCUMENT`, `NOTE` — never the internal id), the share's **title as the hero** (wrapped to at most three lines and auto-fitted 66px → 38px), and `shares.omniscio.com` on a short accent rule at the foot. Each kind gets its own accent colour, so a wall of shares still reads as distinct. An untitled share falls back to a readable hero ("Shared note") rather than an empty plate.

- **Images** become the centerpiece of that same frame — letterboxed (never cropped, so an odd aspect ratio loses nothing) with the title demoted to a caption beneath. Source bytes over 4 MB skip the embed and get the title-hero card instead.
- **Every other kind** (html / react / markdown / svg / pdf / code / text / thread / pasted-text / board) gets the title-hero card.

Rendering is one path for all of them: `share-thumbnail-card.ts` builds the SVG (pure — no Electron, no fs), and `share-thumbnail.ts` rasterizes it through the isolated render-worker (`@resvg/resvg-js` in a `utilityProcess` child, bundled Noto Sans, no Chromium and no system-font load).

The result: paste a share URL into iMessage, Slack, Twitter, Discord, etc. and the unfurl shows a real title + description + a branded per-share card instead of a generic Firebase Hosting fallback. Thumbnail rendering failure does NOT block the publish — `og:image` is simply omitted and `twitter:card` degrades to `summary`, logged as non-fatal.

### Limits

- **Image upload cap**: 22 MB raw bytes. Larger images throw `size-cap` because the base64-expanded HTML would exceed the 25 MB final-blob budget.
- **PDF upload cap**: 18 MB raw bytes. Same reason — base64 expansion is roughly 4/3 × raw.
- **Pasted content cap**: 2 MB UTF-8 (enforced by both the IPC schema and the CLI body schema).
- **Final HTML blob cap**: 25 MB. The builder throws `size-cap` if the rendered HTML exceeds this even when the source bytes were under their individual caps.
- **No per-user share count cap**. The `share_links` table is unbounded; the Settings → Sharing view lists every active row with a Revoke button so you can prune.
- **Expiration**: Optional per share. Choose `Never`, `1 hour`, `1 day`, `7 days`, or `30 days` from the modal; pass `expiresIn` seconds for CLI calls (`60` to `60 * 60 * 24 * 365`). Expired shares return 410 Gone from the Cloud Function and the blob is auto-deleted on the first 410 response.
- **Never-expiring cleanup (Smart Share Reaper)**: A `Never` share (or screen recording) that goes **over a year old AND unviewed for 90+ days** gets a **Keep / Delete inbox card** and a 90-day grace expiry — Keep it (snoozes a year) or let the grace lapse and it's reclaimed. A share still being viewed is never touched. Shipped 2026-07-23; a server-side GCS lifecycle rule backstops shares from users who never reopen the app. See [smart-share-reaper-contract.md](../../.claude/memory/contracts/smart-share-reaper-contract.md).
- **Firebase free-tier quota**: 5 GB storage, 1 GB/day egress (default Spark plan; Blaze plan is unbounded). When the publish path hits a quota error from the Firebase SDK, Omniscio throws `firebase-quota`; the renderer IPC envelope surfaces this with an `actionUrl` of `/settings/storage` so the toast deep-links to the configuration UI.

### Security model

- **Token-as-credential**: The 16-hex token in the URL is the only thing protecting access. Anyone with the URL can view the content. Treat shared URLs the same way you'd treat a "view-only Google Drive link" — fine for non-sensitive content, never for secrets / credentials / private PII.
- **HTML and React run from a separate same-origin document, isolated by a `sandbox` CSP (opaque origin)**: The author's runnable code is NOT inlined into the shell. It is served as its own document at `/s/<token>/run` (stored as `shares/<token>.run.html`) and embedded by the shell via `<iframe src="/s/<token>/run" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads">` — `src`, **not** `srcdoc`. The run doc is served with the response header `Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads` (Claude.ai's flag set minus `allow-same-origin`, and with no `allow-top-navigation`). Why `src` and not `srcdoc`: a `srcdoc` document (`about:srcdoc`) **inherits the embedder's CSP**, so the shell's `script-src 'none'` would block every script — which is exactly why the old `srcdoc` design ran nothing in a real browser (see "Security model" history below). A separately-served document carries its OWN script-permitting policy. The result: scripts **run**, but in an **opaque origin** — they cannot read cookies / localStorage / sessionStorage of the share origin or any sibling share on `shares.omniscio.com`, **cannot persist any web storage at all** (`localStorage` access throws), cannot reach Omniscio, and cannot navigate the top tab. The same `sandbox` CSP is set on the `/run` response itself, so even a direct top-level hit on `/s/<token>/run` is sandboxed — it can never execute as the un-sandboxed share origin. Scripts CAN make `fetch()` to third-party origins, write to their own DOM, and use `alert`/`confirm`/`prompt`. Threat model: the run doc is treated the same as opening a third-party site in a new tab — mutually distrustful with everything else on the host.
- **The shell still serves `script-src 'none'`; it gained `frame-src 'self'`**: The outer page that frames the iframe runs no code of its own (its chrome — title, label, expiry footer — is static). Its CSP keeps `script-src 'none'` so nothing inside the iframe can tamper with the shell, and adds `frame-src 'self'` so it is allowed to frame the same-origin run doc. The shell, not the run doc, is the only script-free trusted surface.
- **The `/run` doc runs the SAME access gate as the shell**: A request to `/s/<token>/run` is checked for revoked / expired / password exactly like `/s/<token>` (the gate logic is shared, so the two routes can never drift). Password protection "just works" with no separate handshake because the unlock cookie is scoped `Path=/s/<token>`, which is also sent to `/s/<token>/run`. The **view cap is counted only by the shell** — the run doc is a sub-resource of an already-counted view, so it never double-counts (and the cap check stays correct on the last allowed view). Known minor limitation: the soft view-cap is not separately enforced on a directly-saved `/run` URL; password / expiry / revoke ARE enforced there.
- **React loads from `unpkg.com`**: React 18 + ReactDOM + Babel-standalone are loaded from the public unpkg CDN — same source Claude.ai's artifact runtime uses (unchanged by the run-doc refactor). The run doc's own `<meta>` CSP pins `script-src` to `'self' https://unpkg.com 'unsafe-inline' 'unsafe-eval'` — Babel needs `unsafe-eval` to transpile — bounded by the opaque-origin sandbox. There's no integrity check on the unpkg responses; an unpkg outage / compromise would affect every published React artifact globally. We accept that risk because it matches Claude.ai's posture and the alternative (bundling React into every share blob) costs ~150 KB per share.
- **Markdown and text are still static**: Even though sibling HTML artifacts run scripts, the text / code renderers HTML-escape the source before injecting it into the share-page shell — a `.txt` file containing `<script>alert(1)</script>` will display the literal characters, not execute. Markdown goes through the audited `sanitize-html` allow-list in the shared renderer (RT-F072): `<script>`, event handlers, `javascript:` / unsafe `data:` URLs, iframes, forms, styles and HTML comments are **removed** (not shown), safe markup such as `<b>` or an inline `<img>` survives, an `<input>` survives only as a disabled checkbox, and `id` / `class` attributes are restricted to the footnote anchors so a document can never clobber the shell's own elements. One sanitizer serves every markdown surface; a second `marked` pipeline under `src/main` is refused by [tests/unit/lint/main-process-markdown-renderer-single-source.test.ts](/tests/unit/lint/main-process-markdown-renderer-single-source.test.ts), and the per-feature rendering is pinned by [tests/unit/services/share-markdown-fixture.test.ts](/tests/unit/services/share-markdown-fixture.test.ts).
- **SVG sanitization**: SVG is the one type where the file content IS HTML-rendered. The builder strips `<script>`, `<foreignObject>`, `<object>`, and `<embed>` elements plus event-handler attributes (`onload=`, `onclick=`, etc.) and `javascript:`/unsafe-`data:` URLs before embedding. (`<object>`/`<embed>` are stripped so a shared SVG can't abuse the share page's `object-src data:` PDF grant.) Defence-in-depth, not zero-trust — if you don't trust an SVG, don't share it.
- **Path allow-list**: Source paths must live inside an active session's working directory OR Omniscio's user-data dir. Symlinks resolve through `fs.realpath` BEFORE the check, defeating symlink-escape attacks.
- **Magic-byte check**: Every type that has a well-known magic-byte signature (PNG, JPEG, GIF, WebP, PDF, SVG) is verified at submit time. Renaming `malware.exe` to `cute.png` and trying to publish it returns `magic-byte-mismatch`.
- **Token entropy**: 16 hex chars = 64 bits. Brute-forcing the URL space is computationally infeasible.
- **No CSRF surface**: Publish is a renderer-process IPC call (no cross-origin path) and a localhost-only CLI POST (bearer-token gated). There is no public HTTP endpoint for publishing.
- **Renderer-side spoofing is impossible**: `sourceType` (the telemetry origin label — `'file-peek'` / `'paste-modal'` / `'chat-link'`) is derived server-side from the request shape (the presence of `sourcePath` vs `pastedContent` vs `attachmentRef`, plus a Zod-whitelisted `origin: 'chat-link'` discriminator for the right-click entry point). It is NOT accepted as an arbitrary IPC field. The renderer cannot lie about where the publish came from.
- **`attachmentRef` resolution is main-process only**: When the chat-link menu publishes an `attachment://<sessionId>/<filename>` URL, the renderer sends `{ sessionId, filename }` only — never an absolute path. The main process resolves it to `<userData>/attachments/<sessionId>/<filename>` via `getAttachmentPath()`, which is already inside the user-data allow-list. The renderer has no way to read or guess the user-data dir.

### Why same-domain, and the real-browser lock

The runnable doc is deliberately served from the **same domain** as the shell (not a separate sandbox domain). The opaque-origin `sandbox` CSP already isolates the code, password "just works" via the `Path=/s/<token>` cookie reaching `/run`, and "don't save anything" is satisfied by the opaque origin (web storage throws). A separate domain would add deployment + cookie-scoping complexity for no extra isolation.

**Security-model history (do not regress):** the earlier v2 design wrapped the runnable code in an `<iframe srcdoc=…>` inside the shell, under the shell's `script-src 'none'` CSP. That was verified only in **jsdom — which does not enforce CSP** — so the "109 tests green" run was misleading: in a real browser an `about:srcdoc` document **inherits the embedder's CSP**, so `script-src 'none'` blocked every script and HTML/React shares shipped **completely inert** (no code ever ran). The fix is the separate-origin `/s/<token>/run` doc described above. The regression guard that v2 lacked is [tests/integration/share-run-doc-browser.test.ts](../../tests/integration/share-run-doc-browser.test.ts) — a **real Chromium** asserts (1) the shell's own inline script is blocked, (2) the `/run` doc's script DOES run, and (3) the run doc is opaque-origin (reading `localStorage` throws). Re-collapsing `/run` back into `srcdoc`, weakening the shell CSP so it runs scripts, or adding `allow-same-origin` turns that test red. Full invariant set: [.claude/memory/contracts/share-artifacts-contract.md](../../.claude/memory/contracts/share-artifacts-contract.md).

### Troubleshooting

**"outside-allowlist"** — the renderer auto-stages out-of-scope `sourcePath` values by copying them into `<userData>/published-pastes/` before validating, so for renderer publishes this error surfaces in two cases: the **stage itself failed** (file moved or deleted between right-click and stage, missing read permission, disk full), or the file was refused outright because its **name is credential-shaped** — a secrets file is never staged, so it has no stage step to fail and no confirm to click through. In the first case the renderer maps it to a clearer "Could not access the file — it may have been moved, deleted, or is locked by another program." toast; the literal `outside-allowlist` string is what the service throws. For CLI publishes (`sourceType: 'cli'`) the allow-list still rejects external paths with this error — scripted callers must stage their own files into an allow-listed dir first.

**"unsupported-type"** — the file extension isn't in the supported-types list above. The publish pipeline deliberately rejects Office / archives / binaries because the share page has no useful renderer for them. Workaround: open the doc, copy its text, paste into the modal as Markdown.

**"magic-byte-mismatch"** — the file's extension and its actual content disagree. Check the file's true type; re-extension it or open it in its real app and Save As the correct type.

**"empty-file"** — zero-byte file. Nothing to publish.

**"size-cap"** — the file (or the rendered HTML for it) exceeds the 25 MB final-blob budget. For images, downscale; for PDFs, compress or split.

**"sign-in-required"** — the user is not signed in, and publishing a share now requires sign-in (the relay authenticates each publish with the user's global-auth ID token). The renderer surfaces "sign in to share"; no share row is created and nothing uploads. **The Share UI now flags this UPFRONT** (so you rarely hit the error): when signed out of the cloud, the Share dialog and the Publish-pasted-content modal show a "Sign in to your account to publish shares" prompt with a Sign-in button and disable their publish button, and the quick-share triggers (the file-peek Share icon, the chat "Share as public artifact" menu item) show a "Sign in to publish shares" tooltip. Sign-in reuses the desktop Google/GitHub OAuth; on the mobile web app it shows a "sign in on the desktop" hint (the OAuth loopback is desktop-only). This is a convenience layer — the relay still enforces sign-in on the actual publish. **It can also surface LATE (2026-08-26):** if your session lapses between the upload and the publish-time mirror read-back, that read-back comes back "signed out", which never self-heals — so the publish fails and rolls back with this same actionable code rather than the opaque `share-mirror-unavailable`, and it raises no "cloud degraded" alert (being signed out is a local state, not a cloud outage).

**"share-network-unreachable"** — the desktop couldn't REACH the relay at all: no internet, a DNS failure, a refused/reset connection, or a request timeout. Detected **structurally** (the errno on `err.code` / `err.cause.code`, or an `HttpTimeoutError`), never from message text, so it's split out from the generic upload failure and points the user at THEIR connection (wifi / VPN / firewall): "Can't reach the share service — check your internet connection (or VPN / firewall) and try again." A user-environment condition, so it does NOT raise a fleet Sentry crash; the CLI route maps it to 502. **This now also covers a publish-time mirror-confirm TIMEOUT** — a share whose mirror write timed out (vs. the relay actively refusing it) surfaces here, quiet, instead of the louder `share-mirror-unavailable` (Sentry 7640659225).

**"share-server-error"** — the relay answered with a **5xx** (`HttpError.status >= 500`) — the problem is on the service side, not the user's connection, so the copy says "The share service is temporarily unavailable — please try again in a few minutes." A real backend fault, so it DOES stay Sentry-visible; the CLI route maps it to 502.

**"firebase-upload-failed"** — the RESIDUAL relay **mint** / **PUT** failure, once offline (`share-network-unreachable`) and server-5xx (`share-server-error`) are split out above — an upload error we couldn't classify further ("Couldn't upload the share. Please try again in a moment."). Check `<userData>/logs/main.log` (the raw cause is logged before the mapped code is thrown). No Firebase key lives on the desktop anymore. The copy no longer blames the connection (that's the network code's job); the CLI route maps it to 502.

**"firebase-upload-failed" / a 500 publishing HTML/React** — historically a full-bleed HTML/React publish (inline `content` OR an allowlisted `path`) could return a generic 500 while plain-text / markdown / a `fullBleed:false` HTML publish still succeeded, because the deployed `shareRelay` Cloud Function was **stale** and rejected a share object kind the desktop app mints (`main.log`: `share relay mint-upload returned 400: objects[N] must be a known object kind`). The functions deploy as a **separate artifact** (no predeploy build hook), so a source kind-change — e.g. the `run.inner.html` kind a full-bleed publish mints — isn't live until redeployed. Two hardenings now prevent the _total_ outage that caused: the relay **skips an unrecognized object kind and mints the rest** instead of 400-ing the whole publish, and it keeps accepting + serving **backward-compat kinds** (`run.inner.html` for shipped older full-bleed clients) — the server must support the UNION of every shipped client's kinds. The desktop also **classifies** a relay mint/upload failure as `firebase-upload-failed` (a 502) rather than a bare 500, so it's diagnosable. If publishes still fail because the deployed functions are genuinely stale or missing a needed kind — **Fix:** `npm run cloud:share:deploy-functions` (builds then redeploys `shareRelay` + `serveShare`). The client↔server object-kind rule (client ⊆ server), the runtime skip, and the deploy + backward-compat coupling are locked by [share-artifacts-contract.md § `object-kinds-client-subset-of-server`](../../.claude/memory/contracts/share-artifacts-contract.md).

> **There are TWO deployments of this relay, and the one the desktop calls is not the Firebase one.** `shareRelay` was ported into the **`amc-back`** repo (`apps/share-api`, on the shared `amc-backend` Cloud Run service at `https://amcback.jls.dev/share`), and current builds POST there **directly**, bypassing Hosting and the Cloud Function entirely — see [share-relay-transport-config.ts](../../src/main/services/share/share-relay-transport-config.ts). `npm run cloud:share:deploy-functions` deploys only the Firebase copy, which now serves **already-installed older builds**; a fix for what the app in front of you is doing usually has to land in `amc-back` and deploy with its own `main` push. The port is a hand-copy with no mechanical sync, so it drifts silently — and it did: it shipped missing the `mirror-changes` op, and every install 400'd once a minute for 11 days with nothing raising an alarm. `apps/share-api/tests/relay-op-parity-guard.test.ts` pins the port's op surface against upstream so that cannot recur silently. **A stale-deploy diagnosis that only checks the Firebase function reads green while the relay users talk to is stale.**

**"firebase-quota"** — Firebase Spark-plan free-tier exceeded. The renderer-side toast deep-links to Settings → Storage; you can either prune old shares or upgrade to the Blaze pay-as-you-go plan.

**"share-mirror-unavailable"** — the bytes uploaded, but the public **share record** (the Firestore "mirror" doc the link-checker gates on) couldn't be confirmed after retries, so the link would 404. Rather than hand back a dead link, publishing **rolls back** (deletes the upload + the row) and surfaces "Couldn't finish publishing the share — the share service is temporarily unavailable. Please try again in a moment." This code is now reserved for a **genuine** confirm failure (the relay refused it — permission / auth, or a 5xx, **or the mirror write acked but a read-back found no serve-visible doc**, **or the read-back couldn't tell for a reason that will never clear on its own — a forbidden read or an open circuit breaker**); a persistent one also raises a single "cloud features degraded" inbox alert and stays Sentry-visible. **A pure network/timeout blip no longer lands here** — it surfaces as the quieter `share-network-unreachable` (no Sentry, no degraded alert), since a timeout is a self-healing transient, not a broken mirror (Sentry 7640659225). Either way the publish rolls back, so you never get a dead link — retry. See the [packaged-firebase-admin-auth postmortem](../../.claude/memory/postmortems/share-mirror-packaged-firebase-admin-auth-postmortem.md) for the 2026-06-29 incident this guards against.

**"Share Not Found" / the link 404s** — a share's bytes live in Cloud Storage, but the public `serveShare` Cloud Function gates on a Firestore **mirror** doc at `shares/{token}`; no doc → 404. This **can no longer happen silently**: publishing now CONFIRMS the mirror landed before returning success — it does not merely trust the relay's write-ack, it **reads the mirror doc back** and only reports success once the doc is actually present (a write that acks but leaves no serve-visible doc is retried, then fails loud with `share-mirror-unavailable`). **When the read-back genuinely can't tell either way, the REASON decides (2026-08-26).** A wall-clock timeout self-heals, so it still accepts the write and leans on the backfill. Signed-out, forbidden, and circuit-breaker-open do NOT self-heal — and the backfill can't repair a signed-out or a different-owner share either — so those now FAIL the publish and roll it back. Accepting them was exactly how a share could hand back a working-looking URL, sit in your list as `published`, and 404 forever. A self-heal backfill also re-writes any mirror doc that went missing for an already-published share — it runs on startup AND periodically (~every 30 min), so a dead link recovers automatically within ~30 min of the cloud recovering, without needing to reopen Omniscio. The mirror is written over the same `google-auth-library` REST/JWT path the uploader uses, NOT `firebase-admin` (which presented the wrong identity in packaged builds — the postmortem above).

**"Published, but failed to copy link to clipboard"** — rare clipboard-permission failure. The share IS published; the URL is in Settings → Sharing (or the CLI `GET /share/list` response). Copy from there.

**Reused link is to old content** — content-hash dedup matches on the exact source bytes **plus** the presentation (title / full-screen / backdrop colour). If you're seeing a stale URL, the bytes AND the presentation are both identical to a previous publish. To force a new share: change the content (e.g. add a trailing newline), change the title / full-screen / backdrop, or Revoke the old share first.

**Published page shows garbled characters (mojibake)** — symbols like an em-dash rendering as `â€"`, a bullet as `â€¢`, or curly quotes as `â€œ` mean the `content` that was sent was already double-encoded before upload: a UTF-8 file read in the wrong code page (classically PowerShell `Get-Content -Raw`, which defaults to Windows-1252 — not UTF-8). The file on disk is fine; the upload step corrupted it, and Omniscio stored exactly what it received. **Fix:** publish the file by **`path`** instead of `content` — Omniscio reads it server-side as UTF-8 and can't be fooled by the caller's read encoding. If you must send `content`, read AND send it as UTF-8. A CLI publish of suspect `content` also comes back with a non-fatal `warning` that flags this. Always verify the LIVE url, not the local file render.

**Full-bleed HTML/React looks small on a phone** — this is by design, not a bug. A fixed-width desktop layout (no responsive CSS) is **scaled down to fit the screen whole** rather than cut off — "small but complete" beats "full-size but stranded". Pinch-to-zoom restores detail. If you want it to _fill_ the phone screen instead of shrinking, give the artifact a responsive layout — a `@media` query or a `<meta name="viewport" content="width=device-width">` — and Omniscio renders it **full-width in its phone layout** (since 2026-08-10 it hosts a responsive page at the device's real width so your mobile breakpoints actually fire; before that a phone laid the framed sub-page out at ~980px and shrank it). A fluid `width:100%` page with no breakpoints simply fills the width directly. A CLI publish of a fixed-width html/react `content` returns a non-fatal `warning` flagging this. (Note: this only affects **newly published** shares; an old share keeps its stored HTML — re-publish it to pick up the fit treatment.)

**View stats stopped syncing / a "N older shares can't sync view stats" inbox notice** — shares published before the 2026-06-30 move to per-account sharing were written with **no owner tag**, so Omniscio's owner-scoped read of their view stats was refused (403). Their **public links still work and keep counting views** — only the in-app stat sync was affected. Omniscio now **auto-reclaims** these on startup: the share relay adopts an un-owned legacy record on write, so a signed-in Omniscio re-owns each one and its stats resume syncing — for a signed-in user this heals itself on the next launch and no notice shows. The deduped inbox notice appears only for whatever is STILL un-synced afterward — you're signed out, or a share is genuinely owned by a _different_ account — and signing in with the owning account then restarting re-links them. (Omniscio also stopped flooding `main.log` with `[ShareMirror] 403` while a share is still un-synced.) Mechanism: the relay's legacy-adopt + the startup reclaim + the persisted per-token mirror-read circuit breaker — [share-artifacts-contract.md § `mirror-written-via-the-relay` + `mirror-reads-trip-a-breaker`](../../.claude/memory/contracts/share-artifacts-contract.md).

## For agents

### CLI

The CLI control server hosts four endpoints that mirror the renderer-side flow — `POST /share/publish`, `GET /share/list`, `POST /share/:token/revoke`, `DELETE /share/:token` — all bearer-token gated and apply-immediately (no inbox approval, because publish doesn't spawn Claude). Each call lands the same `share_artifact_publish` telemetry event with `sourceType: 'cli'` so the Stats view can split CLI traffic from in-app traffic. Full curl recipes, request/response shapes, and error code mapping: [share-cli.md](share-cli.md).

## Related

The overview, the other parts, and everything else worth reading next all sit on [Share Artifacts (publish local files and pasted content as links)](share-artifacts.md).
