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:
- Idempotency-Key. If the network drops after the server wrote the site, a retry with the same key replays the original response instead of publishing again. A date or a CI run id makes a good key.
unchanged. If the files are byte-for-byte the same as the current version, the response says"unchanged":trueand the version does not move. A cron job that runs more often than the data changes does not create noise.--fail-with-body. Errors are JSON withcode,message,retry_after, anddocs_url, so the job log shows the reason.curl --retrytreats429and transient5xxresponses as retryable.
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
409 slug_taken: the token in the job does not own this slug, usually because the secret is missing and the request went out anonymously. Check that the secret reached the environment. Do not switch to thesuggestionslug, or your URL will move.401 unauthorized: the token is not recognized (a typo or the wrong secret).413 request_too_large: over 4 MB in one request. Split it withX-Merge: 1.429 rate_limited: the Free plan allows 60 publishes an hour. Waitretry_afterseconds.
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
- Docs: /docs
- MCP walkthrough: /blog/mcp-publish-walkthrough
- OpenAPI: /openapi.json
- Skill for agents: /skill.md
Questions or corrections: ops@avatar33.com.