deed.page

Blog · 2026-10-05

Publish to deed.page from a cron job or CI pipeline

A lot of agent output is scheduled: a nightly report, docs built in CI, an eval summary after every merge. Each wants one stable URL that always shows the latest result, updated with nobody in the loop.

deed.page fits that shape because publishing is one HTTP request with no email code, no claim link, and no default expiry. This post walks through the setup for unattended jobs: mint a token once, keep the slug fixed, and make every run safe to retry.

Step 1: mint the token once, by hand

Run the first publish yourself, not from the job. It creates the slug and returns a deed_tok_… token exactly once:

mkdir -p site && echo '<!doctype html><h1>Nightly report</h1>' > site/index.html
tar -C ./site -czf /tmp/site.tar.gz .
curl -sS -T /tmp/site.tar.gz "https://deed.page/v1/sites?slug=acme-nightly-report"

The response is 201 with "version":"v1", "permanent":true, "expires_at":null, and the token field. Pick your own slug: it must match ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$, and docs examples such as hello and my-page are reserved.

Store the token in your scheduler's secret store (a GitHub Actions secret, a CI variable marked secret, or a file readable only by the cron user). deed.page keeps only a SHA-256 of the token and never collects an email, so a lost token cannot be recovered. The site stays online, but you would have to publish under a new slug.

Do not let the job mint a token on every run. Each anonymous publish creates a new owner, so the job could never update the same URL.

Step 2: publish with the token on every run

Every later run sends the same slug plus the token. Without X-Merge, the upload replaces the whole site, which is what you want for a regenerated report:

tar -C ./site -czf /tmp/site.tar.gz .
curl -sS --fail-with-body --retry 3 \
  -T /tmp/site.tar.gz \
  "https://deed.page/v1/sites/acme-nightly-report" \
  -H "Authorization: Bearer $DEEDPAGE_TOKEN" \
  -H "Idempotency-Key: nightly-$(date -u +%Y-%m-%d)" \
  -H "X-Client: cron"

The response is 200 with a bumped version (v2, v3, …). The site is served at https://acme-nightly-report.deed.page/ and at https://deed.page/s/acme-nightly-report/.

Three details make this safe to run unattended:

Step 3: wire it into GitHub Actions

A minimal workflow that rebuilds and publishes a report every weekday morning, plus on demand:

name: publish-report
on:
  schedule:
    - cron: "5 13 * * 1-5"
  workflow_dispatch:
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build-report.sh   # writes ./site/index.html
      - name: Publish to deed.page
        env:
          DEEDPAGE_TOKEN: ${{ secrets.DEEDPAGE_TOKEN }}
        run: |
          tar -C ./site -czf /tmp/site.tar.gz .
          curl -sS --fail-with-body --retry 3 \
            -T /tmp/site.tar.gz \
            "https://deed.page/v1/sites/acme-nightly-report" \
            -H "Authorization: Bearer $DEEDPAGE_TOKEN" \
            -H "Idempotency-Key: run-${{ github.run_id }}-${{ github.run_attempt }}" \
            -H "X-Client: github-actions"

GitHub cron schedules run in UTC, so 5 13 * * 1-5 is 9:05 AM in New York during daylight saving time. Swap ./scripts/build-report.sh for whatever produces your files. The same block works in GitLab CI, Buildkite, or a plain crontab entry. Only the way the secret reaches DEEDPAGE_TOKEN changes.

If the build output is over 4 MB, send it in parts: the first request without X-Merge, then the rest with X-Merge: 1 and the same token. The large sites post covers the split.

Step 4: the same thing over MCP

If the scheduled job is itself an agent with MCP tools, call update_site on https://deed.page/mcp with a file map instead of a tarball:

curl -sS https://deed.page/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $DEEDPAGE_TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"update_site","arguments":{
         "slug":"acme-nightly-report",
         "files":{"index.html":"<!doctype html><h1>Nightly report</h1>"}}}}'

The result's structuredContent has the same fields as the REST response: url, version, and unchanged. In Claude Code, claude mcp add --transport http deed-page https://deed.page/mcp registers the server so the agent can call the tool directly.

Failure modes to handle

Permanent or throwaway

A fixed slug with no TTL is the right default for a report people bookmark. For per-PR previews, use a slug with the PR number and an X-Ttl so old previews expire on their own. See opt-in TTLs.

Related

Questions or corrections: ops@avatar33.com.