diff --git a/skills/paperclip-page/README.md b/skills/paperclip-page/README.md
new file mode 100644
index 0000000000..713a8144a9
--- /dev/null
+++ b/skills/paperclip-page/README.md
@@ -0,0 +1,621 @@
+# Paperclip Page Skill
+
+`paperclip-page` publishes static page directories to a Paperclip-controlled S3
+bucket served through CloudFront. It is the durable Paperclip-owned replacement
+for quick `here.now`-style page sharing.
+
+The v1 security posture is:
+
+- CloudFront + ACM + Origin Access Control in front of a private S3 REST origin.
+- Public content only.
+- Dedicated uploader IAM identity, separate from Paperclip attachment storage.
+- No `s3:DeleteObject`, no `aws s3 sync --delete`, and no bucket/IAM/DNS changes
+ from the publish helper.
+- Symlinks, hidden files, unsafe slugs, and accidental overwrites are rejected.
+
+## Agent Quick Start
+
+Build or prepare a static directory with `index.html` at its root:
+
+```bash
+site/
+ index.html
+ assets/app.css
+ assets/app.js
+```
+
+Validate without AWS writes:
+
+```bash
+skills/paperclip-page/scripts/publish.sh ./site --slug demo --dry-run
+```
+
+Publish:
+
+```bash
+skills/paperclip-page/scripts/publish.sh ./site --slug demo
+```
+
+Update an existing page from the same source directory:
+
+```bash
+skills/paperclip-page/scripts/publish.sh ./site --slug demo --update
+```
+
+The helper prints:
+
+- public URL
+- S3 key prefix
+- local ownership state path
+
+## Source Directory Rules
+
+- `index.html` must exist at the directory root.
+- Source directory itself must not be a symlink.
+- No symlinks anywhere in the tree.
+- No hidden files or dot paths in published content.
+- `.paperclip-page/state.json` is allowed and excluded from uploads.
+- Do not publish secrets, credentials, internal logs, private company material,
+ customer data, or regulated data.
+
+Add this to the publishing repo or generated site `.gitignore` when the source
+directory lives in a git checkout:
+
+```gitignore
+.paperclip-page/
+```
+
+## Environment Variables
+
+Required for live publishes:
+
+```bash
+export AWS_REGION=us-east-1
+export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod
+export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing
+export AWS_ACCESS_KEY_ID=...
+export AWS_SECRET_ACCESS_KEY=...
+```
+
+Optional:
+
+```bash
+export PAPERCLIP_PAGE_DEFAULT_PREFIX=""
+export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader
+```
+
+Recommended Paperclip secret names:
+
+- `paperclip-page-aws-access-key-id`
+- `paperclip-page-aws-secret-access-key`
+
+Bind those secrets into publisher agents as `AWS_ACCESS_KEY_ID` and
+`AWS_SECRET_ACCESS_KEY`. Do not reuse Paperclip's internal S3 attachment/object
+storage credentials.
+
+## AWS Setup
+
+Run setup with an operator/admin AWS profile. Agents using this skill should not
+create buckets, mutate IAM, change DNS, or manage CloudFront.
+
+```bash
+export AWS_PROFILE=paperclip-admin
+export AWS_REGION=us-east-1
+export BUCKET=paperclip-pages-prod
+export DOMAIN=pages.paperclip.ing
+export UPLOADER_USER=paperclip-page-uploader
+export CLOUDFRONT_COMMENT="Paperclip pages"
+
+aws sts get-caller-identity --profile "$AWS_PROFILE"
+```
+
+Create the bucket:
+
+```bash
+aws s3api create-bucket \
+ --profile "$AWS_PROFILE" \
+ --region "$AWS_REGION" \
+ --bucket "$BUCKET"
+```
+
+For regions other than `us-east-1`, add:
+
+```bash
+--create-bucket-configuration LocationConstraint="$AWS_REGION"
+```
+
+Disable ACLs and keep ownership bucket-enforced:
+
+```bash
+aws s3api put-bucket-ownership-controls \
+ --profile "$AWS_PROFILE" \
+ --bucket "$BUCKET" \
+ --ownership-controls '{"Rules":[{"ObjectOwnership":"BucketOwnerEnforced"}]}'
+```
+
+Block public access. CloudFront reads through OAC, so the bucket does not need a
+public website policy:
+
+```bash
+aws s3api put-public-access-block \
+ --profile "$AWS_PROFILE" \
+ --bucket "$BUCKET" \
+ --public-access-block-configuration \
+ 'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
+```
+
+Enable versioning:
+
+```bash
+aws s3api put-bucket-versioning \
+ --profile "$AWS_PROFILE" \
+ --bucket "$BUCKET" \
+ --versioning-configuration Status=Enabled
+```
+
+Enable default encryption:
+
+```bash
+aws s3api put-bucket-encryption \
+ --profile "$AWS_PROFILE" \
+ --bucket "$BUCKET" \
+ --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
+```
+
+Upload an operator-managed root `404.html` before creating the distribution:
+
+```bash
+mkdir -p /tmp/paperclip-pages-bootstrap
+printf '
Not foundNot found
\n' \
+ > /tmp/paperclip-pages-bootstrap/404.html
+
+aws s3 cp /tmp/paperclip-pages-bootstrap/404.html "s3://$BUCKET/404.html" \
+ --profile "$AWS_PROFILE" \
+ --content-type text/html \
+ --cache-control 'public,max-age=60'
+```
+
+Create an ACM certificate in `us-east-1` for CloudFront:
+
+```bash
+export ACM_REGION=us-east-1
+
+aws acm request-certificate \
+ --profile "$AWS_PROFILE" \
+ --region "$ACM_REGION" \
+ --domain-name "$DOMAIN" \
+ --validation-method DNS \
+ --idempotency-token paperclippages \
+ > /tmp/paperclip-pages-acm.json
+
+export CERT_ARN="$(jq -r '.CertificateArn' /tmp/paperclip-pages-acm.json)"
+
+aws acm describe-certificate \
+ --profile "$AWS_PROFILE" \
+ --region "$ACM_REGION" \
+ --certificate-arn "$CERT_ARN" \
+ --query 'Certificate.DomainValidationOptions[].ResourceRecord'
+```
+
+Add the returned DNS validation record in Cloudflare, then wait:
+
+```bash
+aws acm wait certificate-validated \
+ --profile "$AWS_PROFILE" \
+ --region "$ACM_REGION" \
+ --certificate-arn "$CERT_ARN"
+```
+
+Create a CloudFront Origin Access Control:
+
+```bash
+aws cloudfront create-origin-access-control \
+ --profile "$AWS_PROFILE" \
+ --origin-access-control-config "{
+ \"Name\":\"paperclip-pages-oac\",
+ \"Description\":\"OAC for $BUCKET\",
+ \"SigningProtocol\":\"sigv4\",
+ \"SigningBehavior\":\"always\",
+ \"OriginAccessControlOriginType\":\"s3\"
+ }" \
+ > /tmp/paperclip-pages-oac.json
+
+export OAC_ID="$(jq -r '.OriginAccessControl.Id' /tmp/paperclip-pages-oac.json)"
+```
+
+Create and publish a CloudFront Function so clean page URLs such as `/demo/`
+load `/demo/index.html` from the S3 REST origin:
+
+```bash
+cat > paperclip-pages-index-router.js <<'EOF'
+function handler(event) {
+ var request = event.request;
+ var uri = request.uri;
+
+ if (uri.endsWith('/')) {
+ request.uri = uri + 'index.html';
+ return request;
+ }
+
+ var lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
+ if (lastSegment.indexOf('.') === -1) {
+ request.uri = uri + '/index.html';
+ }
+
+ return request;
+}
+EOF
+
+aws cloudfront create-function \
+ --profile "$AWS_PROFILE" \
+ --name paperclip-pages-index-router \
+ --function-config 'Comment=Rewrite clean page URLs to index.html,Runtime=cloudfront-js-2.0' \
+ --function-code fileb://paperclip-pages-index-router.js \
+ > /tmp/paperclip-pages-function.json
+
+export FUNCTION_ETAG="$(jq -r '.ETag' /tmp/paperclip-pages-function.json)"
+
+aws cloudfront publish-function \
+ --profile "$AWS_PROFILE" \
+ --name paperclip-pages-index-router \
+ --if-match "$FUNCTION_ETAG" \
+ > /tmp/paperclip-pages-function-live.json
+
+export FUNCTION_ARN="$(jq -r '.FunctionSummary.FunctionMetadata.FunctionARN' /tmp/paperclip-pages-function-live.json)"
+```
+
+Create `cloudfront-config.json`:
+
+```bash
+export CALLER_REFERENCE="paperclip-pages-$(date +%s)"
+
+jq -n \
+ --arg caller "$CALLER_REFERENCE" \
+ --arg comment "$CLOUDFRONT_COMMENT" \
+ --arg domain "$DOMAIN" \
+ --arg bucket "$BUCKET" \
+ --arg oac "$OAC_ID" \
+ --arg functionArn "$FUNCTION_ARN" \
+ --arg cert "$CERT_ARN" \
+ '{
+ CallerReference: $caller,
+ Comment: $comment,
+ Enabled: true,
+ IsIPV6Enabled: true,
+ Aliases: {Quantity: 1, Items: [$domain]},
+ Origins: {
+ Quantity: 1,
+ Items: [{
+ Id: "s3-origin",
+ DomainName: ($bucket + ".s3.amazonaws.com"),
+ OriginAccessControlId: $oac,
+ S3OriginConfig: {OriginAccessIdentity: ""}
+ }]
+ },
+ DefaultRootObject: "index.html",
+ DefaultCacheBehavior: {
+ TargetOriginId: "s3-origin",
+ ViewerProtocolPolicy: "redirect-to-https",
+ AllowedMethods: {Quantity: 2, Items: ["GET", "HEAD"], CachedMethods: {Quantity: 2, Items: ["GET", "HEAD"]}},
+ Compress: true,
+ CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
+ OriginRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf",
+ FunctionAssociations: {
+ Quantity: 1,
+ Items: [{EventType: "viewer-request", FunctionARN: $functionArn}]
+ }
+ },
+ CustomErrorResponses: {
+ Quantity: 1,
+ Items: [{ErrorCode: 403, ResponsePagePath: "/404.html", ResponseCode: "404", ErrorCachingMinTTL: 60}]
+ },
+ ViewerCertificate: {
+ ACMCertificateArn: $cert,
+ SSLSupportMethod: "sni-only",
+ MinimumProtocolVersion: "TLSv1.2_2021"
+ },
+ Restrictions: {GeoRestriction: {RestrictionType: "none", Quantity: 0}}
+ }' > cloudfront-config.json
+```
+
+Create the distribution:
+
+```bash
+aws cloudfront create-distribution \
+ --profile "$AWS_PROFILE" \
+ --distribution-config file://cloudfront-config.json \
+ > /tmp/paperclip-pages-cloudfront.json
+
+export DISTRIBUTION_ID="$(jq -r '.Distribution.Id' /tmp/paperclip-pages-cloudfront.json)"
+export DISTRIBUTION_DOMAIN="$(jq -r '.Distribution.DomainName' /tmp/paperclip-pages-cloudfront.json)"
+```
+
+Grant CloudFront read access to the private bucket:
+
+```bash
+export ACCOUNT_ID="$(aws sts get-caller-identity --profile "$AWS_PROFILE" --query Account --output text)"
+
+jq -n \
+ --arg bucket "$BUCKET" \
+ --arg account "$ACCOUNT_ID" \
+ --arg distribution "$DISTRIBUTION_ID" \
+ '{
+ Version: "2012-10-17",
+ Statement: [{
+ Sid: "AllowCloudFrontServicePrincipalReadOnly",
+ Effect: "Allow",
+ Principal: {Service: "cloudfront.amazonaws.com"},
+ Action: "s3:GetObject",
+ Resource: ("arn:aws:s3:::" + $bucket + "/*"),
+ Condition: {
+ StringEquals: {
+ "AWS:SourceArn": ("arn:aws:cloudfront::" + $account + ":distribution/" + $distribution)
+ }
+ }
+ }]
+ }' > bucket-policy.json
+
+aws s3api put-bucket-policy \
+ --profile "$AWS_PROFILE" \
+ --bucket "$BUCKET" \
+ --policy file://bucket-policy.json
+```
+
+Create the uploader IAM user:
+
+```bash
+aws iam create-user \
+ --profile "$AWS_PROFILE" \
+ --user-name "$UPLOADER_USER"
+```
+
+Create `paperclip-page-uploader-policy.json`. This policy supports collision
+checks and additive uploads under slug prefixes while protecting root bootstrap
+objects such as `404.html`.
+
+```bash
+jq -n \
+ --arg bucket "$BUCKET" \
+ '{
+ Version: "2012-10-17",
+ Statement: [
+ {
+ Sid: "ListPublishedPagePrefixes",
+ Effect: "Allow",
+ Action: ["s3:ListBucket"],
+ Resource: ("arn:aws:s3:::" + $bucket)
+ },
+ {
+ Sid: "ReadPublishedPages",
+ Effect: "Allow",
+ Action: ["s3:GetObject"],
+ Resource: ("arn:aws:s3:::" + $bucket + "/*")
+ },
+ {
+ Sid: "WritePublishedPageObjects",
+ Effect: "Allow",
+ Action: ["s3:PutObject"],
+ Resource: ("arn:aws:s3:::" + $bucket + "/*/*")
+ },
+ {
+ Sid: "DenyReservedRootWrites",
+ Effect: "Deny",
+ Action: ["s3:PutObject", "s3:DeleteObject", "s3:PutObjectTagging"],
+ Resource: [
+ ("arn:aws:s3:::" + $bucket + "/404.html"),
+ ("arn:aws:s3:::" + $bucket + "/index.html")
+ ]
+ }
+ ]
+ }' > paperclip-page-uploader-policy.json
+```
+
+Attach it:
+
+```bash
+aws iam put-user-policy \
+ --profile "$AWS_PROFILE" \
+ --user-name "$UPLOADER_USER" \
+ --policy-name PaperclipPagePublisher \
+ --policy-document file://paperclip-page-uploader-policy.json
+```
+
+Create access keys and treat the output as secret material:
+
+```bash
+aws iam create-access-key \
+ --profile "$AWS_PROFILE" \
+ --user-name "$UPLOADER_USER" \
+ > /tmp/paperclip-page-uploader-key.json
+chmod 600 /tmp/paperclip-page-uploader-key.json
+```
+
+## Cloudflare DNS
+
+Use DNS-only or proxied CNAME to CloudFront. Do not point v1 at the S3 website
+endpoint.
+
+Cloudflare UI:
+
+- Open the `paperclip.ing` zone.
+- Add `CNAME`:
+ - Name: `pages`
+ - Target: the CloudFront domain, for example `d111111abcdef8.cloudfront.net`
+ - Proxy status: DNS only or Proxied
+ - TTL: Auto
+
+API equivalent:
+
+```bash
+export CF_ZONE_ID=
+export CF_API_TOKEN=
+
+curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
+ -H "Authorization: Bearer $CF_API_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data "$(jq -n \
+ --arg name pages \
+ --arg content "$DISTRIBUTION_DOMAIN" \
+ '{type:"CNAME", name:$name, content:$content, ttl:1, proxied:false}')"
+```
+
+Smoke check:
+
+```bash
+curl -I "https://$DOMAIN/404.html"
+```
+
+## Paperclip Secrets
+
+Create secrets from environment variables so values do not land in shell history:
+
+```bash
+export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
+export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
+
+pnpm paperclipai secrets create \
+ --company-id \
+ --name paperclip-page-aws-access-key-id \
+ --value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
+
+pnpm paperclipai secrets create \
+ --company-id \
+ --name paperclip-page-aws-secret-access-key \
+ --value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
+```
+
+Bind runtime env to publishing agents:
+
+```json
+{
+ "AWS_ACCESS_KEY_ID": {
+ "type": "secret_ref",
+ "secretId": "",
+ "version": "latest"
+ },
+ "AWS_SECRET_ACCESS_KEY": {
+ "type": "secret_ref",
+ "secretId": "",
+ "version": "latest"
+ },
+ "AWS_REGION": { "type": "plain", "value": "us-east-1" },
+ "PAPERCLIP_PAGE_BUCKET": { "type": "plain", "value": "paperclip-pages-prod" },
+ "PAPERCLIP_PAGE_BASE_URL": { "type": "plain", "value": "https://pages.paperclip.ing" },
+ "PAPERCLIP_PAGE_DEFAULT_PREFIX": { "type": "plain", "value": "" }
+}
+```
+
+## Install And Attach
+
+Create or update the company skill from this package:
+
+```bash
+pnpm paperclipai skills create \
+ --company-id \
+ --name "Paperclip Page" \
+ --slug paperclip-page \
+ --description "Publish static pages to the Paperclip pages host" \
+ --body-file skills/paperclip-page/SKILL.md
+```
+
+Attach it to an agent:
+
+```bash
+pnpm paperclipai skills agent sync \
+ --company-id \
+ --skill paperclip-page
+```
+
+Ensure the agent can read this directory or copy the package into the installed
+company skill location with `scripts/publish.sh` preserved as executable.
+
+## Credential Rotation
+
+1. Create a second access key:
+
+```bash
+aws iam create-access-key \
+ --profile "$AWS_PROFILE" \
+ --user-name "$UPLOADER_USER" \
+ > /tmp/paperclip-page-uploader-key-rotation.json
+chmod 600 /tmp/paperclip-page-uploader-key-rotation.json
+```
+
+2. Store new secret versions in Paperclip Secrets.
+3. Update agent env bindings to the new versions or `latest`.
+4. Run a dry-run and a small publish smoke.
+5. Disable the old key:
+
+```bash
+aws iam update-access-key \
+ --profile "$AWS_PROFILE" \
+ --user-name "$UPLOADER_USER" \
+ --access-key-id \
+ --status Inactive
+```
+
+6. Delete the old key after the next successful publish:
+
+```bash
+aws iam delete-access-key \
+ --profile "$AWS_PROFILE" \
+ --user-name "$UPLOADER_USER" \
+ --access-key-id
+```
+
+## Troubleshooting
+
+`AccessDenied` on upload:
+
+- Confirm agent env contains the uploader key, not the admin key.
+- Confirm the uploader policy allows `s3:ListBucket`, `s3:GetObject`, and
+ `s3:PutObject`.
+- Confirm uploads target `/...` so the `arn:aws:s3:::/*/*`
+ object ARN matches.
+
+`Slug already exists`:
+
+- Use a different slug.
+- Or run `--update` from the original source directory that has
+ `.paperclip-page/state.json`.
+
+Generated slug collides:
+
+- The helper appends a short suffix for generated slugs when AWS reports a
+ collision. Explicit slugs fail instead of silently changing the URL.
+
+URL 404s after upload:
+
+- Check `curl -I https:////`.
+- Check CloudFront distribution deployment status.
+- Check DNS CNAME target.
+- Check the object exists at `s3:////index.html`.
+
+Stale browser cache:
+
+- The helper uses `Cache-Control: public,max-age=60`.
+- Wait a minute or issue a CloudFront invalidation if the operator wants an
+ immediate refresh.
+
+CloudFront returns 403:
+
+- Confirm the bucket policy references the correct distribution ARN.
+- Confirm OAC is attached to the S3 origin.
+- Confirm the bucket is private and public access block is enabled.
+
+## Public Content Security Notes
+
+Anything published with this skill is public. The tool cannot reliably classify
+generated files, so the publishing agent must inspect content before uploading.
+
+Do not publish:
+
+- API keys, OAuth tokens, cookies, or `.env` files
+- internal customer data
+- private company docs
+- unpublished security reports
+- raw transcripts that may contain secrets
+
+Recovery after accidental overwrite uses S3 versioning. Because v1 uploader
+credentials cannot delete objects, rollback should be performed by an operator
+with admin credentials.
diff --git a/skills/paperclip-page/SKILL.md b/skills/paperclip-page/SKILL.md
new file mode 100644
index 0000000000..333689e0cb
--- /dev/null
+++ b/skills/paperclip-page/SKILL.md
@@ -0,0 +1,90 @@
+---
+name: paperclip-page
+description: >
+ Publish static HTML pages and asset folders to the Paperclip-approved S3 and
+ CloudFront website host. Use when asked to deploy, publish, host, or share a
+ persistent Paperclip page, wireframe viewer, prototype, report, or other static
+ site without using here.now.
+---
+
+# Paperclip Page
+
+Use this skill to publish a static directory to the configured Paperclip pages
+host, for example `https://pages.paperclip.ing//`.
+
+## Requirements
+
+- Source directory contains `index.html` at its root.
+- `aws` CLI v2, `curl`, and `jq` are available on PATH for live publishes.
+- Environment variables are configured:
+ - `PAPERCLIP_PAGE_BUCKET`
+ - `PAPERCLIP_PAGE_BASE_URL`
+ - `AWS_REGION`
+ - AWS credentials via Paperclip Secrets or an approved AWS vault
+- Optional environment variables:
+ - `PAPERCLIP_PAGE_DEFAULT_PREFIX`
+ - `PAPERCLIP_PAGE_AWS_PROFILE`
+
+## Workflow
+
+1. Inspect the source directory and confirm it is public static content only.
+2. Run `scripts/publish.sh --dry-run` to validate local structure and see
+ the resolved URL/prefix.
+3. Choose a slug:
+ - Use `--slug ` when the user gave a stable URL path.
+ - Omit `--slug` to derive one from the source directory name.
+4. Publish:
+
+```bash
+skills/paperclip-page/scripts/publish.sh ./site --slug my-page
+```
+
+5. Return the printed public URL and S3 prefix to the issue/user.
+
+## Update Workflow
+
+Updates are additive overwrites only. The helper never deletes remote objects.
+
+```bash
+skills/paperclip-page/scripts/publish.sh ./site --slug my-page --update
+```
+
+When the target prefix already exists, `--update` requires local ownership proof
+from `./site/.paperclip-page/state.json` generated by an earlier publish from
+that same source directory. Without that state, create a new slug instead of
+overwriting another page.
+
+## Safety Rules
+
+- Publish public content only. Do not publish secrets, customer data, private
+ company material, credentials, or internal logs.
+- Never print AWS secret values.
+- Never change bucket policy, IAM, DNS, CloudFront, or ACM settings from this
+ skill. Setup belongs to an operator runbook, not the publish helper.
+- Never upload outside the configured bucket and prefix.
+- Never use `aws s3 sync --delete` or require `s3:DeleteObject` in v1.
+- The helper forces `--no-follow-symlinks` and fails if any source symlink is
+ present.
+- The helper rejects hidden files and dot-segment paths except its own
+ `.paperclip-page/state.json`.
+- Slugs and prefix segments must use lowercase ASCII letters, digits, and
+ hyphens only.
+- Keep site-wide root objects such as `404.html` operator-managed; publishes
+ always target `/...` or `//...`.
+
+## Troubleshooting
+
+- `Slug already exists`: choose a different slug or use `--update` from the
+ original source directory containing `.paperclip-page/state.json`.
+- `Missing index.html`: build the static site first or point the helper at the
+ directory that contains the root HTML file.
+- `Found symlink`: replace symlinks with real files before publishing.
+- `AccessDenied`: confirm the uploader IAM policy allows `ListBucket`,
+ `GetObject`, and `PutObject` for the configured bucket/prefix, and that the
+ agent received the Paperclip Secrets.
+- Public URL verification failed: check CloudFront deployment/DNS, object
+ existence, and that the distribution uses HTTPS with the private S3 REST
+ origin.
+
+See `README.md` next to this skill for operator setup, AWS policy examples,
+credential rotation, and install/attach commands.
diff --git a/skills/paperclip-page/scripts/publish.sh b/skills/paperclip-page/scripts/publish.sh
new file mode 100755
index 0000000000..2aa3a356ad
--- /dev/null
+++ b/skills/paperclip-page/scripts/publish.sh
@@ -0,0 +1,388 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ publish.sh [--slug slug] [--update] [--dry-run]
+
+Publishes a static directory with a root index.html to the configured Paperclip
+pages bucket and prints the public URL and S3 prefix.
+
+Required environment for live publish:
+ PAPERCLIP_PAGE_BUCKET, PAPERCLIP_PAGE_BASE_URL, AWS_REGION, AWS credentials
+
+Optional environment:
+ PAPERCLIP_PAGE_DEFAULT_PREFIX, PAPERCLIP_PAGE_AWS_PROFILE
+
+Options:
+ --slug SLUG Lowercase URL slug. Allowed: a-z, 0-9, hyphen.
+ --update Additively overwrite an owned existing prefix. Never deletes.
+ --dry-run Validate and print the planned target without AWS writes.
+ --help, -h Show this help.
+EOF
+}
+
+die() {
+ printf 'paperclip-page: %s\n' "$*" >&2
+ exit 1
+}
+
+require_command() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ die "missing required command: $1"
+ fi
+}
+
+normalize_base_url() {
+ local value="$1"
+ value="${value%/}"
+ [[ "$value" == https://* ]] || die "PAPERCLIP_PAGE_BASE_URL must be an https URL"
+ [[ ! "$value" =~ [[:space:]] ]] || die "PAPERCLIP_PAGE_BASE_URL cannot contain whitespace"
+ printf '%s\n' "$value"
+}
+
+validate_segment() {
+ local value="$1"
+ local label="$2"
+
+ [[ -n "$value" ]] || die "$label cannot be empty"
+ [[ "${#value}" -le 64 ]] || die "$label is too long; max length is 64 characters"
+ [[ "$value" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || die "$label must use lowercase letters, digits, and hyphens only"
+ [[ "$value" != "." && "$value" != ".." ]] || die "$label cannot be a dot segment"
+}
+
+normalize_slug() {
+ local value="$1"
+
+ value="${value#/}"
+ value="${value%/}"
+ [[ "$value" != *"/"* ]] || die "slug must be one path segment, not a nested path"
+ validate_segment "$value" "slug"
+ case "$value" in
+ 404|404-html|index|index-html|root|assets)
+ die "slug '$value' is reserved"
+ ;;
+ esac
+ printf '%s\n' "$value"
+}
+
+derive_slug() {
+ local source_dir="$1"
+ local base
+
+ base="$(basename "$source_dir")"
+ base="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"
+ if [[ -z "$base" ]]; then
+ base="paperclip-page"
+ fi
+ printf '%.48s\n' "$base" | sed -E 's/-+$//'
+}
+
+random_suffix() {
+ if command -v openssl >/dev/null 2>&1; then
+ openssl rand -hex 3
+ else
+ od -An -N3 -tx1 /dev/urandom | tr -d ' \n'
+ fi
+}
+
+normalize_default_prefix() {
+ local raw="${1:-}"
+ local segment
+ local normalized=""
+
+ raw="${raw#/}"
+ raw="${raw%/}"
+ [[ "$raw" != *"//"* ]] || die "PAPERCLIP_PAGE_DEFAULT_PREFIX cannot contain empty path segments"
+ if [[ -z "$raw" ]]; then
+ printf '\n'
+ return
+ fi
+
+ IFS='/' read -r -a segments <<<"$raw"
+ for segment in "${segments[@]}"; do
+ validate_segment "$segment" "prefix segment"
+ if [[ -z "$normalized" ]]; then
+ normalized="$segment"
+ else
+ normalized="$normalized/$segment"
+ fi
+ done
+ printf '%s\n' "$normalized"
+}
+
+join_prefix() {
+ local default_prefix="$1"
+ local slug="$2"
+
+ if [[ -n "$default_prefix" ]]; then
+ printf '%s/%s/\n' "$default_prefix" "$slug"
+ else
+ printf '%s/\n' "$slug"
+ fi
+}
+
+aws_base_args=()
+
+aws_cli() {
+ aws "${aws_base_args[@]}" "$@"
+}
+
+object_exists() {
+ local bucket="$1"
+ local prefix="$2"
+ local key
+
+ key="$(aws_cli s3api list-objects-v2 \
+ --bucket "$bucket" \
+ --prefix "$prefix" \
+ --max-keys 1 \
+ --query 'Contents[0].Key' \
+ --output text)"
+ [[ "$key" != "None" && -n "$key" ]]
+}
+
+assert_safe_source_tree() {
+ local source_dir="$1"
+ local found
+
+ [[ ! -L "$source_dir" ]] || die "source directory must not be a symlink"
+ [[ -f "$source_dir/index.html" ]] || die "source directory must contain root index.html"
+
+ found="$(find "$source_dir" -type l -print -quit)"
+ [[ -z "$found" ]] || die "found symlink in source tree: $found"
+
+ found="$(
+ cd "$source_dir"
+ find . -mindepth 1 \
+ \( -path './.paperclip-page' -o -path './.paperclip-page/*' \) -prune -o \
+ \( -name '.*' -o -path '*/.*' \) -print -quit
+ )"
+ [[ -z "$found" ]] || die "hidden files and dot paths are not allowed in published content: $found"
+}
+
+read_state_value() {
+ local state_file="$1"
+ local expression="$2"
+ jq -r "$expression // empty" "$state_file"
+}
+
+assert_update_ownership() {
+ local source_dir="$1"
+ local bucket="$2"
+ local prefix="$3"
+ local state_file="$source_dir/.paperclip-page/state.json"
+ local state_bucket
+ local state_prefix
+
+ [[ -f "$state_file" ]] || die "update of an existing prefix requires ownership state at $state_file"
+ state_bucket="$(read_state_value "$state_file" '.bucket')"
+ state_prefix="$(read_state_value "$state_file" '.prefix')"
+
+ [[ "$state_bucket" == "$bucket" ]] || die "state bucket does not match target bucket"
+ [[ "$state_prefix" == "$prefix" ]] || die "state prefix does not match target prefix"
+}
+
+compute_source_hash() {
+ local source_dir="$1"
+
+ if ! command -v sha256sum >/dev/null 2>&1; then
+ printf 'unavailable\n'
+ return
+ fi
+
+ (
+ cd "$source_dir"
+ find . -type f ! -path './.paperclip-page/*' -print0 |
+ LC_ALL=C sort -z |
+ while IFS= read -r -d '' path; do
+ sha256sum "$path"
+ done
+ ) | sha256sum | awk '{print $1}'
+}
+
+write_state() {
+ local source_dir="$1"
+ local bucket="$2"
+ local prefix="$3"
+ local slug="$4"
+ local url="$5"
+ local base_url="$6"
+ local source_hash="$7"
+ local state_dir="$source_dir/.paperclip-page"
+ local state_file="$state_dir/state.json"
+ local temp_file
+
+ mkdir -p "$state_dir"
+ temp_file="$(mktemp "$state_dir/state.json.tmp.XXXXXX")"
+ jq -n \
+ --arg bucket "$bucket" \
+ --arg prefix "$prefix" \
+ --arg slug "$slug" \
+ --arg url "$url" \
+ --arg baseUrl "$base_url" \
+ --arg publishedAt "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
+ --arg sourceHash "$source_hash" \
+ '{
+ bucket: $bucket,
+ prefix: $prefix,
+ slug: $slug,
+ url: $url,
+ baseUrl: $baseUrl,
+ publishedAt: $publishedAt,
+ sourceHash: $sourceHash,
+ version: 1
+ }' >"$temp_file"
+ mv "$temp_file" "$state_file"
+}
+
+source_arg=""
+slug_arg=""
+update=0
+dry_run=0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --slug)
+ slug_arg="${2:-}"
+ shift 2
+ ;;
+ --update)
+ update=1
+ shift
+ ;;
+ --dry-run)
+ dry_run=1
+ shift
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --*)
+ die "unknown argument: $1"
+ ;;
+ *)
+ if [[ -n "$source_arg" ]]; then
+ die "unexpected positional argument: $1"
+ fi
+ source_arg="$1"
+ shift
+ ;;
+ esac
+done
+
+[[ -n "$source_arg" ]] || {
+ usage >&2
+ exit 1
+}
+
+require_command jq
+require_command find
+require_command sed
+
+[[ -d "$source_arg" ]] || die "source path is not a directory: $source_arg"
+source_dir="$(cd "$source_arg" && pwd -P)"
+assert_safe_source_tree "$source_dir"
+
+bucket="${PAPERCLIP_PAGE_BUCKET:-}"
+base_url="${PAPERCLIP_PAGE_BASE_URL:-}"
+region="${AWS_REGION:-}"
+default_prefix="$(normalize_default_prefix "${PAPERCLIP_PAGE_DEFAULT_PREFIX:-}")"
+
+[[ -n "$bucket" ]] || die "PAPERCLIP_PAGE_BUCKET is required"
+[[ "$bucket" =~ ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$ ]] || die "PAPERCLIP_PAGE_BUCKET does not look like a valid S3 bucket name"
+[[ -n "$base_url" ]] || die "PAPERCLIP_PAGE_BASE_URL is required"
+base_url="$(normalize_base_url "$base_url")"
+
+explicit_slug=0
+if [[ -n "$slug_arg" ]]; then
+ explicit_slug=1
+ slug="$(normalize_slug "$slug_arg")"
+else
+ slug="$(normalize_slug "$(derive_slug "$source_dir")")"
+fi
+
+if [[ "$dry_run" == "0" ]]; then
+ require_command aws
+ require_command curl
+ [[ -n "$region" ]] || die "AWS_REGION is required for live publish"
+ aws_base_args=(--region "$region")
+ if [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
+ aws_base_args+=(--profile "$PAPERCLIP_PAGE_AWS_PROFILE")
+ fi
+fi
+
+prefix="$(join_prefix "$default_prefix" "$slug")"
+target_exists=0
+
+if [[ "$update" == "1" ]]; then
+ assert_update_ownership "$source_dir" "$bucket" "$prefix"
+fi
+
+if [[ "$dry_run" == "0" ]]; then
+ if object_exists "$bucket" "$prefix"; then
+ target_exists=1
+ fi
+
+ if [[ "$target_exists" == "1" && "$update" == "0" ]]; then
+ if [[ "$explicit_slug" == "1" ]]; then
+ die "slug already exists: $slug. Use --update from the owning source directory or choose a new slug."
+ fi
+
+ for _ in 1 2 3 4 5; do
+ candidate="${slug}-$(random_suffix)"
+ candidate="$(printf '%.64s' "$candidate" | sed -E 's/-+$//')"
+ validate_segment "$candidate" "generated slug"
+ candidate_prefix="$(join_prefix "$default_prefix" "$candidate")"
+ if ! object_exists "$bucket" "$candidate_prefix"; then
+ slug="$candidate"
+ prefix="$candidate_prefix"
+ target_exists=0
+ break
+ fi
+ done
+
+ [[ "$target_exists" == "0" ]] || die "could not find an unused generated slug after 5 attempts"
+ fi
+fi
+
+url="${base_url}/${prefix}"
+mode="publish"
+if [[ "$update" == "1" ]]; then
+ mode="update"
+fi
+
+if [[ "$dry_run" == "1" ]]; then
+ cat </dev/null
+
+cat < {
+ for (const dir of tempDirs) {
+ rmSync(dir, { force: true, recursive: true });
+ }
+});
+
+function createSite(name = "paperclip-page-test") {
+ const siteDir = mkdtempSync(join(tmpdir(), `${name}-`));
+ tempDirs.add(siteDir);
+ writeFileSync(join(siteDir, "index.html"), "Paperclip\n");
+ return siteDir;
+}
+
+function writeExecutable(path, body) {
+ writeFileSync(path, body, { mode: 0o755 });
+ chmodSync(path, 0o755);
+}
+
+function writeState(siteDir, state) {
+ mkdirSync(join(siteDir, ".paperclip-page"), { recursive: true });
+ writeFileSync(join(siteDir, ".paperclip-page", "state.json"), `${JSON.stringify(state)}\n`);
+}
+
+function runPublish(args, env = {}) {
+ try {
+ return {
+ output: execFileSync("bash", [scriptPath, ...args], {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PAPERCLIP_PAGE_BUCKET: "paperclip-pages-test",
+ PAPERCLIP_PAGE_BASE_URL: "https://pages.example.test/",
+ ...env,
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ }),
+ status: 0,
+ };
+ } catch (error) {
+ return {
+ output: `${error.stdout ?? ""}${error.stderr ?? ""}`,
+ status: error.status ?? 1,
+ };
+ }
+}
+
+test("publish helper stays executable", () => {
+ assert.equal(statSync(scriptPath).mode & 0o111, 0o111);
+});
+
+test("dry run validates and prints the planned target without requiring AWS", () => {
+ const result = runPublish([
+ createSite(),
+ "--slug",
+ "demo-page",
+ "--dry-run",
+ ]);
+
+ assert.equal(result.status, 0);
+ assert.match(result.output, /^paperclip-page dry run$/m);
+ assert.match(result.output, /^mode: publish$/m);
+ assert.match(result.output, /^bucket: paperclip-pages-test$/m);
+ assert.match(result.output, /^prefix: demo-page\/$/m);
+ assert.match(result.output, /^url: https:\/\/pages\.example\.test\/demo-page\/$/m);
+});
+
+test("dry run normalizes a safe default prefix", () => {
+ const result = runPublish(
+ [createSite(), "--slug", "demo-page", "--dry-run"],
+ { PAPERCLIP_PAGE_DEFAULT_PREFIX: "/reports/launches/" },
+ );
+
+ assert.equal(result.status, 0);
+ assert.match(result.output, /^prefix: reports\/launches\/demo-page\/$/m);
+ assert.match(result.output, /^url: https:\/\/pages\.example\.test\/reports\/launches\/demo-page\/$/m);
+});
+
+test("dry run update requires matching local ownership state", () => {
+ const siteDir = createSite();
+ const missingState = runPublish([siteDir, "--slug", "demo-page", "--update", "--dry-run"]);
+
+ assert.notEqual(missingState.status, 0);
+ assert.match(missingState.output, /requires ownership state/);
+
+ writeState(siteDir, {
+ bucket: "paperclip-pages-test",
+ prefix: "demo-page/",
+ });
+
+ const result = runPublish([siteDir, "--slug", "demo-page", "--update", "--dry-run"]);
+
+ assert.equal(result.status, 0);
+ assert.match(result.output, /^mode: update$/m);
+});
+
+test("rejects nested slugs", () => {
+ const result = runPublish([createSite(), "--slug", "nested/path", "--dry-run"]);
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.output, /slug must be one path segment/);
+});
+
+test("rejects hidden files in the source tree", () => {
+ const siteDir = createSite();
+ mkdirSync(join(siteDir, "assets"));
+ writeFileSync(join(siteDir, "assets", ".secret"), "do not publish\n");
+
+ const result = runPublish([siteDir, "--slug", "demo-page", "--dry-run"]);
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.output, /hidden files and dot paths are not allowed/);
+});
+
+test("live publish writes state before URL verification", () => {
+ const siteDir = createSite();
+ const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
+ tempDirs.add(binDir);
+
+ writeExecutable(
+ join(binDir, "aws"),
+ `#!/usr/bin/env bash
+set -euo pipefail
+while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
+ shift 2
+done
+if [[ "$1" == "s3api" ]]; then
+ echo "None"
+ exit 0
+fi
+if [[ "$1" == "s3" && "$2" == "sync" ]]; then
+ exit 0
+fi
+echo "unexpected aws call: $*" >&2
+exit 1
+`,
+ );
+ writeExecutable(
+ join(binDir, "curl"),
+ `#!/usr/bin/env bash
+set -euo pipefail
+echo "simulated CloudFront propagation miss" >&2
+exit 22
+`,
+ );
+
+ const result = runPublish([siteDir, "--slug", "demo-page"], {
+ AWS_REGION: "us-east-1",
+ PATH: `${binDir}:${process.env.PATH}`,
+ });
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.output, /simulated CloudFront propagation miss/);
+ assert.equal(existsSync(join(siteDir, ".paperclip-page", "state.json")), true);
+
+ const state = JSON.parse(readFileSync(join(siteDir, ".paperclip-page", "state.json"), "utf8"));
+ assert.equal(state.bucket, "paperclip-pages-test");
+ assert.equal(state.prefix, "demo-page/");
+ assert.equal(state.url, "https://pages.example.test/demo-page/");
+});