InstaShare for Claude Code

Share the whole Claude Code session, not just the final answer.

Turn a Claude Code conversation into a clean public link—with prompts, responses, tool calls, code, and context organized for the person reading it. No account is required to make your first share.

Want a dashboard for your links?Sign in to manage shares
One-request sharing

Say “share this chat.” The skill finds the active session and returns a ready-to-send link.

Transparent client

The complete skill is readable and editable, including its redaction and upload behavior.

Public by link

Recipients only need a browser—but anyone who obtains the URL can read the shared content.

From terminal to useful handoff

Share in the flow of your work.

InstaShare uses the structured session Claude Code already saves locally, so the reasoning and technical trail stay together without copying a terminal by hand.

01

Ask to share

Use one natural-language request from the session you want to publish.

02

Prepare locally

The skill finds the active JSONL and redacts recognized credential patterns in memory.

03

Create the page

InstaShare renders the transcript, tool activity, code, images, and session context.

04

Keep control

Save the delete link, update the same URL later, or claim the device into an account.

What you can share

A record people can actually review.

Preserve the conversation and the work behind it, then give readers a clean path from the high-level exchange into exact tool inputs and results.

Security and privacy

Know the boundary before you publish.

The skill performs best-effort local secret redaction before upload. It does not make the resulting page private: transcripts and extracted images are available to anyone with their URLs. Review sensitive sessions and keep control links safe.

Install

A skill you can read before you run.

InstaShare is a plain-text Claude Code skill—not a daemon or background agent. The manual path keeps every instruction visible and is the recommended installation method.

Recommended

Install the reviewed SKILL.md

  1. 1

    Open the published SKILL.md and read the behavior it defines.

  2. 2

    Save it at the path for your operating system.

  3. 3

    Restart Claude Code, then say “share this chat.”

Windows%USERPROFILE%\.claude\skills\instashare\SKILL.md
macOS / Linux~/.claude/skills/instashare/SKILL.md
Open SKILL.md
One-line installer

Convenient, but less transparent: these commands download and execute whatever the remote endpoint serves at that moment. Prefer the manual path when possible.

Windows · PowerShell

iex (irm https://instashare.to/install.ps1)

macOS / Linux

curl -fsSL https://instashare.to/install.sh | bash
Advanced: upload JSONL directly

Direct API uploads do not automatically run the skill's local redaction step. Inspect or scrub the transcript before sending it.

Any Claude Code session JSONL

curl -X POST https://instashare.to/api/chats \
  -H "Content-Type: text/plain" \
  --data-binary "@session.jsonl"
Read the API and automation guide
Inspect and copy the complete skill

This is the full published client. Read it, edit it, and make the sharing policy yours.

---
name: instashare
description: InstaShare — upload the current Claude Code chat session to instashare.to and return a public share link. The session lives at `~/.claude/projects/<slug>/<session-id>.jsonl`; this skill POSTs that file to the InstaShare API in one shell call and prints the returned URL. Use when the user asks to "share this chat", "make a link to this conversation", "InstaShare this", "publish this session", or similar.
tools: Bash
---

# InstaShare

Upload the current Claude Code session JSONL to the InstaShare API and report the public link. One shell call does it — given this session's id, the transcript is `~/.claude/projects/<slug>/<session-id>.jsonl`.

If this session has already been shared in a previous run, the script reuses the existing URL: it reads a sidecar file `<jsonl-path>.instashare.json` (created by the first upload) and `PUT`s the new content to the same slug. The public URL stays the same so any link the user already pasted continues to work and now shows the latest transcript.

## Configuration

**No account required.** The first share creates an anonymous device identifier (`claim_token`) that the skill stores in `~/.claude/instashare/credentials.json`. Every subsequent share is grouped under the same device. The skill prints a one-time **claim link** so the user can sign in with Google later and pull every share from this device into a personal `/mine` page — past and future. Clicking the claim link is optional; the public share URLs work either way.

Endpoint defaults to `https://instashare.to`. Override with the `INSTASHARE_API_URL` env var (use `http://localhost:3000` for local dev).

## Fill in the session info

Before you run the snippet, set three variables from *your own* current session. They are live-session state that never reaches the transcript, so the script cannot derive them itself.

- `$sessionId` / `session_id` → **which conversation to upload.** See "Identify your session" below. This one is a correctness requirement, not cosmetics.
- `$sessionModel` / `session_model` → your current model exactly as shown in the status line, including any context-window marker, e.g. `Opus 4.8 (1M context)`.
- `$sessionEffort` / `session_effort` → your current reasoning effort, e.g. `medium` (the bare word, no "effort" suffix).

`sessionModel` and `sessionEffort` feed the shared page's header line (`Opus 4.8 (1M context) with medium effort · Claude Max`; plan is read from `~/.claude.json` automatically). If you genuinely don't know one of them, leave it as the empty string — the page degrades gracefully, and the server still parses the base model out of the transcript. Don't guess or invent values.

## Identify your session

**Set `$sessionId` whenever you can.** The script's fallback is the newest `.jsonl` by mtime, and that is a guess, not an identification: Claude Code stores every session for a working directory in the same project folder, so a second session running in the same cwd — another terminal, a background agent, a teammate on the same box — writes to a sibling file that may be newer than yours. Publishing the wrong transcript is silent, and the resulting URL is public before anyone notices.

Ways to get your session id, best first:

- **Your scratchpad path.** If your system prompt lists a scratchpad directory, the session id is the UUID path segment: `…/Temp/claude/<project-slug>/<session-id>/scratchpad`.
- **Ask the user.** `/status` shows it, and it is in the window title of some terminals.

If you cannot determine it, leave it empty. The script then falls back to mtime **and aborts** if more than one transcript in the folder was touched in the last 30 minutes, rather than picking one at random. On abort, get the id and re-run — do not defeat the guard by widening the window or picking a file by hand.

Whichever path you take, the script echoes `Session file: <name>` before uploading. Check it against your own session id.

## Run exactly one of these

Pick by platform. The script computes the slug itself, resolves the session file from `$sessionId` (aborting rather than guessing when it is unset and the folder looks ambiguous), scrubs known secret patterns from the body in memory, decides POST-vs-PUT from the sidecar, uploads the scrubbed body, and prints the URL.

### Windows (PowerShell)

```powershell
$apiBase = if ($env:INSTASHARE_API_URL) { $env:INSTASHARE_API_URL } else { 'https://instashare.to' }
$credentialsDir  = Join-Path $env:USERPROFILE ".claude\instashare"
$credentialsFile = Join-Path $credentialsDir "credentials.json"
$deviceHostname  = $env:COMPUTERNAME

# Live-session state that never reaches the transcript, so the script cannot
# derive it — BEFORE running, fill these from the current session (see "Fill in
# the session info" and "Identify your session" below).
# $sessionId picks WHICH conversation is uploaded and is a correctness
# requirement; leave it '' only if you truly cannot determine it, and expect the
# ambiguity guard below to abort rather than guess.
# Model/effort are cosmetic: '' degrades gracefully (the server parses the base
# model from the transcript). Plan is read from disk.
$sessionId     = ''   # e.g. '3d5e8218-ab51-4dbb-b7a6-58b24a21e788'
$sessionModel  = ''   # e.g. 'Opus 4.8 (1M context)'
$sessionEffort = ''   # e.g. 'medium'
$sessionPlan   = ''
$claudeConfig = Join-Path $env:USERPROFILE ".claude.json"
if (Test-Path $claudeConfig) {
  try {
    $orgType = (Get-Content $claudeConfig -Raw -Encoding UTF8 | ConvertFrom-Json).oauthAccount.organizationType
    switch ($orgType) {
      'claude_max'        { $sessionPlan = 'Claude Max' }
      'claude_pro'        { $sessionPlan = 'Claude Pro' }
      'claude_team'       { $sessionPlan = 'Claude Team' }
      'claude_enterprise' { $sessionPlan = 'Claude Enterprise' }
    }
  } catch { }
}

# Load the per-machine claim token if we have one. Server uses its hash to
# group every share from this device under a single identity so the user
# can later claim them all to an account.
$claimToken = $null
if (Test-Path $credentialsFile) {
  try {
    $claimToken = (Get-Content $credentialsFile -Raw -Encoding UTF8 | ConvertFrom-Json).claimToken
  } catch { }
}

$cwd = (Get-Location).Path
# Slug = cwd with ':' and '\' replaced by '-'. .Replace (literal) avoids the
# doubled-backslash regex literal that the sandbox path guard can misread as a
# protected '\\' target (it pairs any such literal with a destructive cmdlet).
$slug = $cwd.Replace(':', '-').Replace('\', '-')
$projDir = Join-Path $env:USERPROFILE ".claude\projects\$slug"

# Pick the transcript. $sessionId is an identification; mtime is only a guess,
# because every session sharing this cwd writes into the same project folder and
# a concurrent one can be newer. So mtime is the fallback, and it refuses to
# choose when the folder shows signs of a second live session.
$src = $null
if ($sessionId) {
  $candidate = Join-Path $projDir "$sessionId.jsonl"
  if (-not (Test-Path $candidate)) {
    Write-Error "No transcript for session '$sessionId' in $projDir. Nothing uploaded."
    exit 1
  }
  $src = Get-Item $candidate
} else {
  $all = @(Get-ChildItem "$projDir\*.jsonl" -ErrorAction Stop | Sort-Object LastWriteTime -Descending)
  if ($all.Count -eq 0) { Write-Error "No session files in $projDir"; exit 1 }
  $rivals = @($all | Where-Object { $_.LastWriteTime -gt (Get-Date).AddMinutes(-30) })
  if ($rivals.Count -gt 1) {
    Write-Error ("Ambiguous session: {0} transcripts in {1} were written in the last 30 minutes ({2}). Another Claude Code session is live in this directory, so the newest file may not be yours. Set `$sessionId and re-run. Nothing uploaded." -f `
      $rivals.Count, $projDir, (($rivals | ForEach-Object { $_.Name }) -join ', '))
    exit 1
  }
  $src = $all[0]
}
Write-Output ("Session file: " + $src.Name)

$body = Get-Content $src.FullName -Raw -Encoding UTF8
$sidecar = "$($src.FullName).instashare.json"

# Redact common secrets before upload. Curated patterns only — provider-specific
# first so e.g. OPENAI_API_KEY=sk-... is labelled openai-key rather than env-secret.
# Each rule has its own replacement string so cookie/auth-header rules can preserve
# the surrounding header name and quoting.
$patterns = @(
  @{ name = 'anthropic-key';     regex = 'sk-ant-[A-Za-z0-9_\-]{20,}';                                              replacement = '[REDACTED:anthropic-key]' },
  @{ name = 'openai-key';        regex = 'sk-(?!ant-)[A-Za-z0-9_\-]{20,}';                                          replacement = '[REDACTED:openai-key]' },
  @{ name = 'aws-access-key-id'; regex = '\b(?:AKIA|ASIA)[A-Z0-9]{16}\b';                                           replacement = '[REDACTED:aws-access-key-id]' },
  @{ name = 'github-token';      regex = '\b(?:gh[posru]_|github_pat_)[A-Za-z0-9_]{20,}\b';                         replacement = '[REDACTED:github-token]' },
  @{ name = 'slack-token';       regex = 'xox[abprs]-[A-Za-z0-9\-]{10,}';                                           replacement = '[REDACTED:slack-token]' },
  @{ name = 'stripe-key';        regex = '\b(?:sk|pk|rk)_live_[A-Za-z0-9]{20,}\b';                                  replacement = '[REDACTED:stripe-key]' },
  @{ name = 'google-api-key';    regex = '\bAIza[A-Za-z0-9_\-]{35}\b';                                              replacement = '[REDACTED:google-api-key]' },
  @{ name = 'jwt';               regex = '\beyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b'; replacement = '[REDACTED:jwt]' },
  @{ name = 'http-auth';         regex = '(?i)Authorization:\s*(?:Basic|Bearer|Digest|Token|OAuth)\s+[A-Za-z0-9+/=._\-]{8,}'; replacement = 'Authorization: [REDACTED:http-auth]' },
  # Credentials inside a connection URI: mongodb+srv://user:pass@host, https://user:pass@host, ...
  # None of the token-shaped rules match these, and `const URI = '...'` misses the *_SECRET= rule.
  @{ name = 'uri-credentials';   regex = '(?i)\b(mongodb(?:\+srv)?|https?|redis|rediss|amqps?|postgres(?:ql)?|mysql|ftp)://[^:/\s"''\\]+:[^@\s"''\\]{4,}@'; replacement = '$1://[REDACTED:uri-credentials]@' },
  # CLI basic-auth flag: curl -u user:pass / --user user:pass
  @{ name = 'basic-auth-flag';   regex = '(?<=\s)(?:-u|--user)\s+["'']?[A-Za-z0-9_\-.@]+:[^\s"'']{6,}["'']?';                 replacement = '-u [REDACTED:basic-auth]' },
  @{ name = 'cookie-header';     regex = '(?i)(?:Cookie|Set-Cookie):\s*[A-Za-z_][A-Za-z0-9_\-]*=[^"''\\\r\n]{10,}'; replacement = '[REDACTED:cookie-header]' },
  @{ name = 'curl-cookie-arg';   regex = '(?<=\s)(?:-b|--cookie)\s+''[A-Za-z_][A-Za-z0-9_\-]*=[^''\\\r\n]{10,}''';  replacement = "-b '[REDACTED:cookie-header]'" },
  # Bare cookie chains (no `Cookie:` header in front) — 3+ name=value pairs separated by `; `.
  # Catches pasted DevTools output, COOKIE=… shell vars, heredoc cookie files, etc.
  # `(?<!\\)` avoids starting a match right after a JSON-escape backslash
  # (e.g. inside `"…cookie\nname=val…"`), which would otherwise corrupt the
  # JSON by leaving an orphan `\`.
  @{ name = 'cookie-chain';      regex = '(?<!\\)(?:[A-Za-z_][A-Za-z0-9_\-]{2,}=[^;"''\\\r\n]{8,};\s+){2,}[A-Za-z_][A-Za-z0-9_\-]{2,}=[^;"''\\\r\n]{8,}'; replacement = '[REDACTED:cookie-chain]' },
  @{ name = 'pem-private-key';   regex = '-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----'; replacement = '[REDACTED:pem-private-key]' },
  # InstaShare's own tokens. A prior run of this skill prints the delete URL (and,
  # on first run, the claim URL) into the transcript, and the sidecar/credentials
  # JSON may get read into the chat — re-sharing the session must not publish them.
  # Matches the URL forms and deleteToken/claimToken JSON fields, plain or JSONL-escaped.
  @{ name = 'instashare-token';  regex = '(/delete\?token=|/claim\?key=|\\?"(?:deleteToken|claimToken)\\?"\s*:\s*\\?")[A-Za-z0-9_\-]{16,}'; replacement = '$1[REDACTED:instashare-token]' }
)
$redactionSummary = [ordered]@{}
foreach ($p in $patterns) {
  $matchCount = ([regex]::Matches($body, $p.regex)).Count
  if ($matchCount -gt 0) {
    $body = [regex]::Replace($body, $p.regex, $p.replacement)
    $redactionSummary[$p.name] = $matchCount
  }
}
$envRx = '\b([A-Z][A-Z0-9_]{0,40}(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD))\s*=\s*([A-Za-z0-9_\-+/=]{16,})'
$envCount = ([regex]::Matches($body, $envRx)).Count
if ($envCount -gt 0) {
  $body = [regex]::Replace($body, $envRx, '$1=[REDACTED:env-secret]')
  $redactionSummary['env-secret'] = $envCount
}

# Gzip the body: the host accepts at most 4.5 MB per request, and a session compresses 5-10x.
$rawBytes = (New-Object System.Text.UTF8Encoding $false).GetBytes($body)
$gzStream = New-Object System.IO.MemoryStream
$gzip = New-Object System.IO.Compression.GZipStream($gzStream, [System.IO.Compression.CompressionMode]::Compress)
$gzip.Write($rawBytes, 0, $rawBytes.Length)
$gzip.Close()
$gzBody = $gzStream.ToArray()

function Build-Headers {
  param($Token)
  $h = @{ 'x-device-hostname' = $deviceHostname; 'Content-Encoding' = 'gzip' }
  if ($Token) { $h['x-claim-token'] = $Token }
  if ($sessionModel)  { $h['x-session-model']  = $sessionModel }
  if ($sessionEffort) { $h['x-session-effort'] = $sessionEffort }
  if ($sessionPlan)   { $h['x-session-plan']   = $sessionPlan }
  return $h
}

$resp = $null
if (Test-Path $sidecar) {
  try {
    $prev = Get-Content $sidecar -Raw -Encoding UTF8 | ConvertFrom-Json
    $putUri = "$apiBase/api/chats/$($prev.slug)"
    if ($prev.deleteToken) { $putUri = "$putUri`?token=$($prev.deleteToken)" }
    $resp = Invoke-RestMethod -Uri $putUri -Method Put -Body $gzBody `
      -ContentType 'text/plain; charset=utf-8' -Headers (Build-Headers $claimToken)
  } catch {
    $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
    if ($code -ne 404 -and $code -ne 401 -and $code -ne 403) { throw }
  }
}

if (-not $resp) {
  try {
    $resp = Invoke-RestMethod -Uri "$apiBase/api/chats" -Method Post `
      -Body $gzBody -ContentType 'text/plain; charset=utf-8' -Headers (Build-Headers $claimToken)
  } catch {
    $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
    if ($code -eq 401 -and $claimToken) {
      # Server revoked / forgot our claim token. Neutralize the stored copy and
      # retry anonymously; the server mints a fresh one we save below. We blank
      # the file with '{}' instead of Remove-Item so the sandbox's static guard
      # doesn't see a destructive cmdlet here and block the whole script — it
      # otherwise pairs Remove-Item with a backslash literal and reads it as a
      # delete of the protected root path '\\'. (On next run '{}' parses to no
      # claimToken, so the device falls back to anonymous exactly as a missing
      # file would.)
      '{}' | Out-File -FilePath $credentialsFile -Encoding utf8
      $claimToken = $null
      $resp = Invoke-RestMethod -Uri "$apiBase/api/chats" -Method Post `
        -Body $gzBody -ContentType 'text/plain; charset=utf-8' -Headers (Build-Headers $null)
    } else {
      throw
    }
  }
  # Save the returned claim token (new on first run, same on subsequent — but
  # always overwrite to support server-side rotation cleanly).
  if ($resp.claimToken) {
    New-Item -ItemType Directory -Force -Path $credentialsDir | Out-Null
    @{ claimToken = $resp.claimToken } | ConvertTo-Json |
      Out-File -FilePath $credentialsFile -Encoding utf8
  }
  $deleteToken = if ($resp.deleteUrl -and $resp.deleteUrl -match 'token=') { ($resp.deleteUrl -split 'token=')[-1] } else { '' }
  @{ slug = $resp.slug; deleteToken = $deleteToken; url = $resp.url } |
    ConvertTo-Json | Out-File -FilePath $sidecar -Encoding utf8
}

Write-Output $resp.url
Write-Output ("Delete: " + $resp.deleteUrl)
if (-not $resp.linked -and $resp.claimUrl) {
  Write-Output ("Make these yours: " + $resp.claimUrl)
}
if ($redactionSummary.Count -gt 0) {
  $summary = ($redactionSummary.GetEnumerator() | ForEach-Object { "$($_.Value) $($_.Key)" }) -join ', '
  Write-Output "Redacted before upload: $summary"
}
```

### macOS / Linux (Bash)

```bash
api_base="${INSTASHARE_API_URL:-https://instashare.to}"
credentials_dir="$HOME/.claude/instashare"
credentials_file="$credentials_dir/credentials.json"
device_hostname=$(hostname 2>/dev/null || echo unknown)

# Live-session state that never reaches the transcript, so the script cannot
# derive it — BEFORE running, fill these from the current session (see "Fill in
# the session info" and "Identify your session" below).
# session_id picks WHICH conversation is uploaded and is a correctness
# requirement; leave it empty only if you truly cannot determine it, and expect
# the ambiguity guard below to abort rather than guess.
# Model/effort are cosmetic: empty degrades gracefully (the server parses the
# base model from the transcript). Plan is read from disk.
session_id=""       # e.g. '3d5e8218-ab51-4dbb-b7a6-58b24a21e788'
session_model=""    # e.g. 'Opus 4.8 (1M context)'
session_effort=""   # e.g. 'medium'
session_plan=""
if [ -f "$HOME/.claude.json" ]; then
  org_type=$(python3 -c 'import json,sys; print((json.load(open(sys.argv[1])).get("oauthAccount") or {}).get("organizationType","") or "")' "$HOME/.claude.json" 2>/dev/null || echo "")
  case "$org_type" in
    claude_max)        session_plan="Claude Max" ;;
    claude_pro)        session_plan="Claude Pro" ;;
    claude_team)       session_plan="Claude Team" ;;
    claude_enterprise) session_plan="Claude Enterprise" ;;
  esac
fi
session_headers=()
[ -n "$session_model" ]  && session_headers+=(-H "x-session-model: $session_model")
[ -n "$session_effort" ] && session_headers+=(-H "x-session-effort: $session_effort")
[ -n "$session_plan" ]   && session_headers+=(-H "x-session-plan: $session_plan")

# Load the per-machine claim token if we have one. Server uses its hash to
# group every share from this device under a single identity so the user
# can later claim them all to an account.
claim_token=""
if [ -f "$credentials_file" ]; then
  claim_token=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("claimToken","") or "")' "$credentials_file" 2>/dev/null || echo "")
fi

cwd="$(pwd)"
slug=$(echo "$cwd" | sed 's#/#-#g')
proj_dir="$HOME/.claude/projects/$slug"

# Pick the transcript. session_id is an identification; mtime is only a guess,
# because every session sharing this cwd writes into the same project folder and
# a concurrent one can be newer. So mtime is the fallback, and it refuses to
# choose when the folder shows signs of a second live session.
if [ -n "$session_id" ]; then
  src="$proj_dir/$session_id.jsonl"
  [ -f "$src" ] || { echo "No transcript for session '$session_id' in $proj_dir. Nothing uploaded." >&2; exit 1; }
else
  src=$(ls -t "$proj_dir"/*.jsonl 2>/dev/null | head -1)
  [ -z "$src" ] && { echo "No session files in $proj_dir" >&2; exit 1; }
  # -mmin is portable across GNU and BSD find; -newermt is not.
  rivals=$(find "$proj_dir" -maxdepth 1 -name '*.jsonl' -mmin -30 2>/dev/null | wc -l | tr -d ' ')
  if [ "$rivals" -gt 1 ]; then
    echo "Ambiguous session: $rivals transcripts in $proj_dir were written in the last 30 minutes. Another Claude Code session is live in this directory, so the newest file may not be yours. Set session_id and re-run. Nothing uploaded." >&2
    exit 1
  fi
fi
echo "Session file: $(basename "$src")"
sidecar="$src.instashare.json"
tmp=$(mktemp)
redacted=$(mktemp)
resp=""

# Redact common secrets before upload. Curated patterns only — provider-specific
# first so e.g. OPENAI_API_KEY=sk-... is labelled openai-key rather than env-secret.
summary=$(python3 - "$src" "$redacted" <<'PY'
import re, sys
# (regex, replacement). Provider-specific rules first so e.g. OPENAI_API_KEY=sk-...
# is labelled openai-key rather than env-secret. Cookie/auth-header rules keep
# their surrounding header name or curl flag intact.
PATTERNS = [
    ('anthropic-key',     r'sk-ant-[A-Za-z0-9_\-]{20,}',                                              '[REDACTED:anthropic-key]'),
    ('openai-key',        r'sk-(?!ant-)[A-Za-z0-9_\-]{20,}',                                          '[REDACTED:openai-key]'),
    ('aws-access-key-id', r'\b(?:AKIA|ASIA)[A-Z0-9]{16}\b',                                           '[REDACTED:aws-access-key-id]'),
    ('github-token',      r'\b(?:gh[posru]_|github_pat_)[A-Za-z0-9_]{20,}\b',                         '[REDACTED:github-token]'),
    ('slack-token',       r'xox[abprs]-[A-Za-z0-9\-]{10,}',                                           '[REDACTED:slack-token]'),
    ('stripe-key',        r'\b(?:sk|pk|rk)_live_[A-Za-z0-9]{20,}\b',                                  '[REDACTED:stripe-key]'),
    ('google-api-key',    r'\bAIza[A-Za-z0-9_\-]{35}\b',                                              '[REDACTED:google-api-key]'),
    ('jwt',               r'\beyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b', '[REDACTED:jwt]'),
    ('http-auth',         r'(?i)Authorization:\s*(?:Basic|Bearer|Digest|Token|OAuth)\s+[A-Za-z0-9+/=._\-]{8,}', 'Authorization: [REDACTED:http-auth]'),
    # Credentials inside a connection URI: mongodb+srv://user:pass@host, https://user:pass@host, ...
    # None of the token-shaped rules match these, and `URI = '...'` misses the *_SECRET= rule.
    ('uri-credentials',   r'''(?i)\b(mongodb(?:\+srv)?|https?|redis|rediss|amqps?|postgres(?:ql)?|mysql|ftp)://[^:/\s"'\\]+:[^@\s"'\\]{4,}@''', r'\1://[REDACTED:uri-credentials]@'),
    # CLI basic-auth flag: curl -u user:pass / --user user:pass
    ('basic-auth-flag',   r'''(?<=\s)(?:-u|--user)\s+["']?[A-Za-z0-9_\-.@]+:[^\s"']{6,}["']?''', '-u [REDACTED:basic-auth]'),
    ('cookie-header',     r'''(?i)(?:Cookie|Set-Cookie):\s*[A-Za-z_][A-Za-z0-9_\-]*=[^"'\\\r\n]{10,}''', '[REDACTED:cookie-header]'),
    ('curl-cookie-arg',   r"""(?<=\s)(?:-b|--cookie)\s+'[A-Za-z_][A-Za-z0-9_\-]*=[^'\\\r\n]{10,}'""", "-b '[REDACTED:cookie-header]'"),
    # Bare cookie chains (no `Cookie:` header in front) — 3+ name=value pairs
    # separated by `; `. Catches pasted DevTools output, COOKIE=… shell vars,
    # heredoc cookie files, etc. `(?<!\\)` avoids starting a match right after
    # a JSON-escape backslash (e.g. inside `"…cookie\nname=val…"`), which
    # would otherwise corrupt the JSON by leaving an orphan `\`.
    ('cookie-chain',      r'''(?<!\\)(?:[A-Za-z_][A-Za-z0-9_\-]{2,}=[^;"'\\\r\n]{8,};\s+){2,}[A-Za-z_][A-Za-z0-9_\-]{2,}=[^;"'\\\r\n]{8,}''', '[REDACTED:cookie-chain]'),
    ('pem-private-key',   r'-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----', '[REDACTED:pem-private-key]'),
    # InstaShare's own tokens. A prior run of this skill prints the delete URL
    # (and, on first run, the claim URL) into the transcript, and the
    # sidecar/credentials JSON may get read into the chat — re-sharing the
    # session must not publish them. Matches the URL forms and
    # deleteToken/claimToken JSON fields, plain or JSONL-escaped.
    ('instashare-token',  r'(/delete\?token=|/claim\?key=|\\?"(?:deleteToken|claimToken)\\?"\s*:\s*\\?")[A-Za-z0-9_\-]{16,}', r'\1[REDACTED:instashare-token]'),
]
text = open(sys.argv[1], 'r', encoding='utf-8').read()
counts = {}
for name, rx, repl in PATTERNS:
    n = len(re.findall(rx, text))
    if n:
        counts[name] = n
        text = re.sub(rx, repl, text)
text, n = re.subn(
    r'\b([A-Z][A-Z0-9_]{0,40}(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD))\s*=\s*([A-Za-z0-9_\-+/=]{16,})',
    r'\1=[REDACTED:env-secret]', text)
if n:
    counts['env-secret'] = n
open(sys.argv[2], 'w', encoding='utf-8').write(text)
print(', '.join(f'{v} {k}' for k, v in counts.items()))
PY
)

# Gzip the body: the host accepts at most 4.5 MB per request, and a session compresses 5-10x.
# Without gzip on the machine, the plain file goes up as before.
upload_body="$redacted"
encoding_headers=()
if command -v gzip >/dev/null 2>&1 && gzip -c "$redacted" > "$redacted.gz"; then
  upload_body="$redacted.gz"
  encoding_headers=(-H 'Content-Encoding: gzip')
fi

post_chats() {
  local with_claim="$1"
  local headers=(-H "x-device-hostname: $device_hostname")
  if [ -n "$with_claim" ]; then headers+=(-H "x-claim-token: $with_claim"); fi
  curl -sS -o "$tmp" -w '%{http_code}' \
    -X POST "$api_base/api/chats" \
    "${headers[@]}" \
    "${session_headers[@]}" \
    "${encoding_headers[@]}" \
    -H 'Content-Type: text/plain; charset=utf-8' \
    --data-binary "@$upload_body"
}

if [ -f "$sidecar" ]; then
  prev_slug=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["slug"])' "$sidecar")
  prev_token=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("deleteToken","") or "")' "$sidecar")
  put_uri="$api_base/api/chats/$prev_slug"
  [ -n "$prev_token" ] && put_uri="$put_uri?token=$prev_token"
  put_headers=(-H "x-device-hostname: $device_hostname")
  [ -n "$claim_token" ] && put_headers+=(-H "x-claim-token: $claim_token")
  code=$(curl -sS -o "$tmp" -w '%{http_code}' \
    -X PUT "$put_uri" \
    "${put_headers[@]}" \
    "${session_headers[@]}" \
    "${encoding_headers[@]}" \
    -H 'Content-Type: text/plain; charset=utf-8' \
    --data-binary "@$upload_body")
  if [ "$code" = "200" ]; then resp="$(cat "$tmp")"; fi
fi

if [ -z "$resp" ]; then
  code=$(post_chats "$claim_token")
  if [ "$code" = "401" ] && [ -n "$claim_token" ]; then
    # Server revoked / forgot our claim token. Drop it and retry anonymously.
    rm -f "$credentials_file"
    claim_token=""
    code=$(post_chats "")
  fi
  if [ "$code" != "200" ] && [ "$code" != "201" ]; then
    cat "$tmp" >&2
    rm -f "$tmp" "$redacted" "$redacted.gz"
    echo "" >&2
    echo "Upload failed (HTTP $code)." >&2
    exit 1
  fi
  resp="$(cat "$tmp")"
  # Save / refresh claim token. Always overwrite so server-side rotation works.
  new_claim=$(echo "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("claimToken","") or "")' 2>/dev/null || echo "")
  if [ -n "$new_claim" ]; then
    mkdir -p "$credentials_dir"
    python3 -c 'import json,sys; json.dump({"claimToken": sys.argv[1]}, open(sys.argv[2],"w"))' "$new_claim" "$credentials_file"
    chmod 600 "$credentials_file" 2>/dev/null || true
  fi
  echo "$resp" | python3 -c '
import json, sys
d = json.load(sys.stdin)
url = d.get("deleteUrl", "")
token = url.split("token=")[-1] if "token=" in url else ""
print(json.dumps({"slug": d["slug"], "deleteToken": token, "url": d["url"]}))
' > "$sidecar"
fi
rm -f "$tmp" "$redacted" "$redacted.gz"

echo "$resp" | python3 -c '
import sys, json
d = json.load(sys.stdin)
print(d["url"])
print("Delete:", d["deleteUrl"])
if not d.get("linked") and d.get("claimUrl"):
    print("Make these yours:", d["claimUrl"])
'
[ -n "$summary" ] && echo "Redacted before upload: $summary"
```

The command prints `Session file: <name>` first, then the public URL, then a delete URL. Until the device has been claimed, a further line offers the **claim URL** that links every share from this device to a Google account. After claiming, that line disappears and shares show up immediately at `<api_base>/mine`.

## After the command runs

0. **Check the `Session file:` line against your own session id** before you hand over the link. If it names a different transcript you have published someone else's conversation: give the user the delete URL immediately and say so plainly, then re-run with `$sessionId` set. A share that is live for a minute is still a share.
1. **Report the public URL** to the user — one line, clickable.
2. **Show the delete URL** on a second line so they can revoke the share later. Opening it in a browser shows a confirmation page; nothing is removed until they click the "Delete forever" button. The token is one-shot per chat — if it's lost, the chat can't be deleted again.
3. **If the skill printed a `Make these yours:` line**, mention that opening it (and signing in with Google) attaches *every* share from this device — past and future — to a `/mine` page. This is optional; the public link works without it.
4. **Note what was redacted, if anything**: when a `Redacted before upload: …` line appears, the skill replaced matches of a curated pattern set (AWS / GitHub / Slack / Stripe / OpenAI / Anthropic / Google keys, JWTs, PEM private-key blocks, HTTP `Authorization: Basic|Bearer|…` headers, `Cookie:` / `Set-Cookie:` header values, curl `-b 'cookie=val; …'` and `-u user:pass` flags, credentials embedded in connection URIs like `mongodb+srv://user:pass@host`, InstaShare's own delete/claim tokens from a previous run of this skill, and `*_KEY=` / `*_TOKEN=` / `*_SECRET=` / `*_PASSWORD=` assignments) with `[REDACTED:<kind>]` markers before upload. The scan is best-effort — custom or proprietary token formats won't match — so still suggest reviewing the public link if the chat may contain anything else sensitive.
5. **Passwords the patterns can't recognise are your problem, not the script's.** A bare password quoted in prose (`the ES password is hunter2`), a proprietary token format, or a credential the user pasted mid-conversation will upload verbatim. If you know the transcript contains one — because you read it from a memory file, a `.env`, or a secrets manager earlier in the session — add a literal rule for it to `$patterns` before running, and say so when you report the link.

## How this works

- **The session id identifies the session; mtime only guesses at it.** Claude Code names each transcript `<session-id>.jsonl` and files every session for a working directory under the same project slug, so `<slug>/<session-id>.jsonl` is an exact address. `ls -t … | head -1` is not: it silently resolves to whichever session in that folder was written to last, which is a different conversation whenever a second Claude Code session shares the cwd. That failure is invisible — the upload succeeds, returns a URL, and publishes someone else's transcript. Hence `$sessionId` up front, mtime only as a fallback, and an abort when two transcripts in the folder are both recently active.
- **The slug is the cwd with `/`, `\`, `:` replaced by `-`, case preserved.** That matches the directory Claude Code creates, including any leading dash on macOS/Linux (`/Users/foo` → `-Users-foo`). Lowercasing it or stripping the leading dash will miss the directory on case-preserving filesystems.
- **The body is the file with common secrets scrubbed in place.** Before POST/PUT the snippet scans `$body` against a curated pattern set (provider API keys, JWTs, PEM private-key blocks, HTTP `Authorization` / `Cookie` / `Set-Cookie` header values, curl `-b 'cookies'` flags, InstaShare's own delete/claim tokens printed by a previous run, and `*_KEY=` / `*_TOKEN=` / `*_SECRET=` / `*_PASSWORD=` style env assignments) and replaces matches with `[REDACTED:<kind>]`. Everything else is uploaded verbatim. Secrets matching one of the patterns therefore never leave the machine — they're scrubbed before the HTTPS body is even constructed. The original `.jsonl` on disk is never modified.
- **The scan is best-effort.** It targets the well-known token formats listed above. Custom or proprietary tokens, free-form passwords in prose, and high-entropy strings without a recognizable prefix won't match. Still surface the public-link warning so the user can decide whether to share, and recommend reviewing the link if the chat may contain anything else sensitive.
- **Sidecar makes the URL stable across reruns.** After the first POST the script writes `<jsonl-path>.instashare.json` containing `{slug, deleteToken, url}`. On the next invocation it `PUT`s to `/api/chats/<slug>?token=<deleteToken>`, which replaces the stored JSONL and recomputes stats while preserving the slug, created-at, and delete token. If the chat was deleted server-side (PUT returns 404) or the sidecar is stale/tampered (401/403), the script falls back to POST and overwrites the sidecar. Delete the sidecar manually to force a brand-new URL.
- **The claim token is per-machine, not per-share.** `~/.claude/instashare/credentials.json` is written on the first upload and reused thereafter. It's sent as `x-claim-token` on POST and PUT so the server can group all uploads from this device. If the server revokes it (the user clicked Revoke on `/account`), the next upload retries anonymously, the server mints a fresh token, and the user sees a fresh claim URL.
- **Session info is sent as headers, not baked into the body.** `x-session-model`, `x-session-effort`, and `x-session-plan` ride alongside the upload so the shared page can show e.g. `Opus 4.8 (1M context) with medium effort · Claude Max`. Plan comes from `~/.claude.json` (`oauthAccount.organizationType`); model/effort are filled in by the agent from the live session because the harness never writes them to the transcript (the persisted `message.model` is the bare id without the context-window marker, and effort isn't recorded at all). All three are optional — the server parses the base model from the transcript as a fallback, so a header-less or headless upload still shows the model.
- **The original session file is never modified.** Only the sidecar (a small JSON file next to the .jsonl) and the per-machine credentials file (`~/.claude/instashare/credentials.json`) are written locally; the .jsonl itself is read-only from the script's perspective.

Compare approaches

InstaShare or Claude Code's native sharing?

Native sharing is useful when its built-in workflow fits. InstaShare emphasizes a readable public web transcript, transparent local preprocessing, anonymous-first publishing, stable updates, and explicit deletion controls.

See the comparison

Questions, answered

Before you share a session.

Practical answers about visibility, redaction, ownership, updates, and deletion.

How do I share a Claude Code chat?

Add the free InstaShare skill to Claude Code, then say "share this chat" in any session. InstaShare locates the active transcript, applies its local redaction rules, uploads the prepared copy, and returns a public link that anyone can open in a browser.

Can I share a Claude conversation without an account?

Yes. Sharing is anonymous-first and does not require signup. Each upload returns a one-shot delete link and an optional claim link. Signing in later lets you gather shares from that device on your My shares page.

Who can read a shared conversation?

Anyone with the public link can read it. Links are not listed in a public directory, but they are not password-protected or restricted to invited readers. Treat a share URL as sensitive and send it only to the intended audience.

Does InstaShare remove API keys and secrets?

The published skill scans for common credential formats and replaces recognized matches locally before upload. This is a best-effort safeguard, not a guarantee: custom credentials, sensitive prose, filenames, tool output, and images can still reveal information. Review sensitive sessions before sharing.

What does a shared transcript include?

A share can include prompts, assistant responses, tool calls and results, commands, code, diffs, timestamps, session metadata, and images captured in the source transcript. The viewer organizes that material into a readable conversation and a more detailed technical view.

Can I update a share without changing its URL?

Yes. The skill saves a small local sidecar after the first upload. Sharing the same session again uses those controls to refresh the existing page, so links already sent to collaborators continue to work.

How do I delete or revoke a shared chat?

Use the one-shot delete URL returned when the share is created, or claim the device and delete owned shares from My shares while signed in. Deletion removes the hosted page, but it cannot retract copies that recipients already saved.

Can I customize the sharing behavior?

Yes. InstaShare is distributed as a readable SKILL.md file. You can add organization-specific redaction patterns, require confirmation before upload, block selected projects, remove tool output, or otherwise adapt the workflow to your policy.

Make the work behind the answer easy to share.

Install the readable skill, share your first session, and keep the delete link it returns.