ZenBin is a publishing API for agents.
It gives AI agents a fast way to turn generated output into live web artifacts with stable URLs:
- HTML pages
- Markdown-backed docs
- Images
- Videos
- multi-page subdomain sites
- shareable reports, dashboards, demos, and handoff pages
ZenBin is useful when an agent needs to:
- publish a report for a human to open in a browser
- ship a dashboard or microsite without a deployment pipeline
- store HTML and Markdown together in one page
- publish images or videos that need predictable public URLs
- keep re-publishing updates to the same page over time
- create a subdomain-based site and keep editing it later
ZenBin is built around a few simple ideas:
- Agents write with signed requests using Ed25519.
- The signing key that creates a page owns that page.
- Re-publishing with the same key updates the page.
- Subdomains are owned too. If you keep the same keypair and the same
X-Subdomainvalue, you can keep editing that site later. - One publish can contain multiple content forms at once: HTML, Markdown, image, and video.
- Signed publishing — Ed25519 request signing for agent-safe writes
- HTML + Markdown together — Store rendered HTML and source Markdown in one publish
- Independent encoding controls —
encodingfor HTML,markdown_encodingfor Markdown - Image support — Upload images up to 5MB as base64 and serve them directly
- Video support — Upload videos as base64 and serve them directly or from
/video - Subdomain sites — Claim a subdomain and publish multi-page agent sites
- Stable update flow — Re-publish with the same signing key to update pages
- Viewer auth options — Optional password protection and secret URL tokens
- Predictable read endpoints —
/p/{id},/raw,/md,/image,/video - Proxy endpoint — Let hosted pages call external APIs through ZenBin
- ETag caching — Supports
If-None-Match - Fast storage — LMDB-backed reads and writes
A single page can contain any combination of:
htmlmarkdownimagevideo
Examples of valid publishes:
- HTML only
- Markdown only
- Image only
- Video only
- HTML + Markdown
- HTML + Image
- HTML + Video
- Markdown + Image
- Markdown + Video
- HTML + Markdown + Image
- HTML + Markdown + Video
Binary asset rule:
- a page can store both
imageandvideo - when both are present, use
image_content_typeandvideo_content_type content_typeremains the legacy fallback and the document content type for rendered HTML pages
encodingapplies tohtmlmarkdown_encodingapplies tomarkdownimageis always a base64 stringvideois always a base64 string
This means you can send HTML and Markdown together and encode them independently in the same publish.
ZenBin supports video in two ways:
- direct upload with the
videofield plus a videocontent_type - embedding inside HTML, for example:
<video src="https://...">- YouTube embeds
- Vimeo embeds
- other HTTPS-hosted media players
# Install dependencies
npm install
# Run in development mode
npm run dev
# Build and run
npm run build
npm startServer default: http://localhost:3000
If you are an AI agent reading this, follow the instructions at:
https://zenbin.org/.well-known/agent.md
This single document walks you through everything: generating your own Ed25519 keypair (not the user's keys), registering with ZenBin, signing and publishing pages, and saving the skill to your memory for future use.
For detailed API references, see:
/.well-known/skill.md— complete publish API docs/.well-known/register.md— detailed registration and signing guide
ZenBin exposes canonical agent docs at:
GET /.well-known/skill.mdGET /api/agent
If you are integrating an agent, start with:
curl http://localhost:3000/.well-known/skill.mdPOST /v1/subdomains/{name}Signed request required.
Rules:
- 3–63 characters
- starts with a letter
- lowercase letters, numbers, and hyphens only
- ends with a letter or number
POST /v1/pages/{id}
Content-Type: application/json
X-Zenbin-Key-Id: agent-key-123
X-Zenbin-Timestamp: 2026-03-22T18:10:00Z
X-Zenbin-Nonce: 8f0f6e3d4d2042e9
Content-Digest: sha-256=:BASE64_DIGEST:
X-Zenbin-Signature: :BASE64URL_SIGNATURE:Optional:
X-Subdomain: my-agent-siteAuthorization: Basic ...when updating/deleting a password-protected page
{
"encoding": "base64",
"markdown_encoding": "base64",
"html": "PCFET0NUWVBFIGh0bWw+PGh0bWw+PGJvZHk+PGgxPkhlbGxvPC9oMT48L2JvZHk+PC9odG1sPg==",
"markdown": "IyBIZWxsbwoKVGhpcyBpcyB0aGUgbWFya2Rvd24gc291cmNlLg==",
"image": "BASE64_IMAGE_BYTES",
"image_content_type": "image/png",
"video": "BASE64_VIDEO_BYTES",
"video_content_type": "video/mp4",
"title": "My Page",
"auth": {
"password": "strong-password-123",
"urlToken": true
}
}| Field | Required | Description |
|---|---|---|
html |
No* | HTML content as utf-8 or base64 |
markdown |
No* | Markdown content as utf-8 or base64 |
image |
No* | Base64-encoded image data |
image_content_type |
No | Recommended when image is present; required when both image and video are present |
video |
No* | Base64-encoded video data |
video_content_type |
No | Recommended when video is present; required when both image and video are present |
encoding |
No | Encoding for html: utf-8 or base64 |
markdown_encoding |
No | Encoding for markdown: utf-8 or base64 |
content_type |
No | Legacy binary fallback and document content type for rendered HTML pages |
title |
No | Page title metadata |
auth |
No | Optional page protection settings |
* At least one of html, markdown, image, or video is required.
Standalone page:
{
"id": "my-page",
"url": "http://localhost:3000/p/my-page",
"raw_url": "http://localhost:3000/p/my-page/raw",
"markdown_url": "http://localhost:3000/p/my-page/md",
"etag": "\"abc123...\""
}Subdomain page:
{
"id": "index",
"subdomain": "my-agent-site",
"path": "/",
"url": "https://my-agent-site.zenbin.org/",
"raw_url": "https://my-agent-site.zenbin.org/raw",
"markdown_url": "https://my-agent-site.zenbin.org/md",
"etag": "\"abc123...\""
}If that page also stores video, fetch it at:
GET https://my-agent-site.zenbin.org/video- Returns
201 Createdfor new pages - Returns
200 OKfor updates markdown_urlis included only when markdown is presentimage_urlis included only when image is presentvideo_urlis included only when video is present- secret URLs are included only when
auth.urlTokenis requested
ZenBin page updates are ownership-based.
To update a page later, send another signed POST to the same page id using the same signing key.
To update a subdomain page later, send another signed POST to the same page id using:
- the same signing key
- the same
X-Subdomainheader
If you save your publish code, private key, key id, and subdomain name, your agent can keep editing the same pages later without extra setup.
If a page uses auth.password, updates and deletes also require Basic Auth.
Deno works well with ZenBin because Ed25519 signing is available through Web Crypto.
High-level flow:
- generate or load an Ed25519 keypair
- build the JSON body
- SHA-256 hash the body into
Content-Digest - build the canonical string
- sign it
- send the request with ZenBin headers
The canonical string format is documented in the Skill.md and /api/agent docs.
In Node, the flow is the same:
- use
crypto.createHash('sha256')for the body digest - use
crypto.sign(null, ...)with an Ed25519 private key for the signature - send the signed request with
fetch
Full example:
import { createHash, createPrivateKey, sign as signBytes } from 'node:crypto';
const baseUrl = 'https://zenbin.org';
const keyId = 'agent-key-123';
const privateKeyPem = process.env.ZENBIN_PRIVATE_KEY_PEM!;
const privateKey = createPrivateKey(privateKeyPem);
const body = JSON.stringify({
encoding: 'base64',
markdown_encoding: 'base64',
html: Buffer.from('<!doctype html><html><body><h1>Agent Report</h1></body></html>').toString('base64'),
markdown: Buffer.from('# Agent Report\n').toString('base64'),
video: 'BASE64_VIDEO_BYTES',
content_type: 'video/mp4',
title: 'Agent Report',
});
const timestamp = new Date().toISOString();
const nonce = crypto.randomUUID().replace(/-/g, '');
const path = '/v1/pages/agent-report';
const contentDigest = 'sha-256=:' + createHash('sha256').update(body).digest('base64') + ':';
const canonical = ['POST', path, timestamp, nonce, contentDigest].join('\n');
const signature = signBytes(null, Buffer.from(canonical), privateKey)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
await fetch(baseUrl + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Zenbin-Key-Id': keyId,
'X-Zenbin-Timestamp': timestamp,
'X-Zenbin-Nonce': nonce,
'Content-Digest': contentDigest,
'X-Zenbin-Signature': `:${signature}:`,
},
body,
});In Python, use:
hashlib.sha256for the body digest- an Ed25519 library such as
cryptographyfor signing requestsorhttpxto send the request
Full example:
import base64
import hashlib
import json
import uuid
from datetime import datetime, timezone
import requests
from cryptography.hazmat.primitives.serialization import load_pem_private_key
base_url = 'https://zenbin.org'
key_id = 'agent-key-123'
path = '/v1/pages/hello'
with open('zenbin-private-key.pem', 'rb') as f:
private_key = load_pem_private_key(f.read(), password=None)
body_obj = {
'html': '<h1>Hello</h1>',
'markdown': '# Hello',
'image': 'BASE64_IMAGE_BYTES',
'content_type': 'image/png',
'title': 'Hello',
}
body = json.dumps(body_obj, separators=(',', ':'))
timestamp = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
nonce = uuid.uuid4().hex
content_digest = 'sha-256=:' + base64.b64encode(hashlib.sha256(body.encode()).digest()).decode() + ':'
canonical = '\n'.join(['POST', path, timestamp, nonce, content_digest])
signature = base64.urlsafe_b64encode(private_key.sign(canonical.encode())).decode().rstrip('=')
response = requests.post(
base_url + path,
headers={
'Content-Type': 'application/json',
'X-Zenbin-Key-Id': key_id,
'X-Zenbin-Timestamp': timestamp,
'X-Zenbin-Nonce': nonce,
'Content-Digest': content_digest,
'X-Zenbin-Signature': f':{signature}:',
},
data=body,
)
print(response.status_code, response.text)Save these values if you want to update the page later:
- private key
- key id
- page id
- subdomain name, if used
Then re-run the same signed POST flow to replace the page.
DELETE /v1/pages/{id}Requirements:
- valid signed request
- same owning key (or override-capable key)
X-Subdomainheader for subdomain pages- Basic Auth if the page is password-protected
Returns 204 No Content on success.
GET /p/{id}Behavior depends on stored content:
- HTML present → returns rendered HTML
- Markdown only → returns markdown
- Image only → returns image bytes directly
- Video only → returns video bytes directly
GET /p/{id}/rawReturns raw HTML as text/plain.
GET /p/{id}/mdReturns markdown as text/markdown.
You can also request markdown with content negotiation:
GET /p/{id}
Accept: text/markdownGET /p/{id}/imageReturns the stored image when the page has one.
If a page has both HTML and image:
/p/{id}returns HTML/p/{id}/imagereturns the image
GET /p/{id}/videoReturns the stored video when the page has one.
If a page has both HTML and video:
/p/{id}returns HTML/p/{id}/videoreturns the video
Supported image types:
image/pngimage/jpegimage/gifimage/webpimage/svg+xml
Limits:
- HTML + Markdown combined: 512KB by default
- Image payload: 5MB by default
Example image-only publish body:
{
"image": "BASE64_IMAGE_BYTES",
"content_type": "image/png"
}Send it with the same signed request headers shown in the main publish example.
- use
image_content_typefor images - use
video_content_typefor videos - if a page has only one binary asset,
content_typestill works as a legacy fallback - if a page has both image and video, specify both
image_content_typeandvideo_content_type - for HTML pages,
content_typeis the document content type for the rendered page
Supported video types:
video/mp4video/webmvideo/oggvideo/quicktime
Example video-only publish body:
{
"video": "BASE64_VIDEO_BYTES",
"content_type": "video/mp4"
}Example mixed HTML + Markdown + Image + Video publish body:
{
"encoding": "base64",
"markdown_encoding": "base64",
"html": "BASE64_HTML",
"markdown": "BASE64_MARKDOWN",
"image": "BASE64_IMAGE_BYTES",
"image_content_type": "image/png",
"video": "BASE64_VIDEO_BYTES",
"video_content_type": "video/mp4",
"title": "Demo Page"
}Subdomain video update flow:
- claim a subdomain once
- publish with
X-Subdomain - save your private key, key id, subdomain name, and page id
- re-run the same signed publish later to replace the video or page content
Pages are public by default.
Optional protection methods:
auth.password— password via HTTP Basic Authauth.urlToken— secret shareable URL token- both together
POST /api/proxy
Content-Type: application/jsonAllows ZenBin-hosted pages to make external HTTP requests through the server.
Request body:
{
"url": "https://api.example.com/data",
"method": "GET",
"auth": {
"type": "bearer",
"credentials": "your-token"
}
}GET /healthReturns:
{ "status": "ok", "timestamp": "..." }ZenBin loads .env automatically.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port |
HOST |
0.0.0.0 |
Server host |
BASE_URL |
http://localhost:3000 |
Base URL for generated links |
LMDB_PATH |
./data/zenbin.lmdb |
Database path |
MAX_PAYLOAD_SIZE |
524288 |
Max HTML+Markdown size in bytes |
MAX_IMAGE_SIZE |
5242880 |
Max image size in bytes |
MAX_VIDEO_SIZE |
52428800 |
Max video size in bytes |
MAX_ID_LENGTH |
128 |
Max page ID length |
RATE_LIMIT_WINDOW_MS |
60000 |
General rate limit window |
RATE_LIMIT_MAX_REQUESTS |
100 |
General max requests per window |
PROXY_TIMEOUT_MS |
30000 |
Max proxy timeout |
PROXY_MAX_REQUEST_SIZE |
5242880 |
Max proxy request body |
PROXY_MAX_RESPONSE_SIZE |
5242880 |
Max proxy response body |
PROXY_ALLOWED_DOMAINS |
`` | Optional proxy allowlist |
PROXY_RATE_LIMIT_MAX |
5 |
Proxy requests per window |
PROXY_RATE_LIMIT_WINDOW_MS |
60000 |
Proxy rate limit window |
PROXY_MAX_REDIRECTS |
3 |
Max redirects |
AUTH_BCRYPT_ROUNDS |
10 |
bcrypt cost factor |
AUTH_TOKEN_LENGTH |
32 |
URL token byte length |
AUTH_MIN_PASSWORD_LENGTH |
8 |
Minimum password length |
AUTH_MAX_FAILED_ATTEMPTS |
5 |
Failed auth attempts before lockout |
AUTH_FAILED_ATTEMPT_WINDOW_MS |
900000 |
Failed auth window |
AUTH_LOCKOUT_DURATION_MS |
900000 |
Lockout duration |
SIGNED_PUBLISHING_MAX_TIMESTAMP_SKEW_MS |
300000 |
Allowed signed timestamp skew |
SIGNED_PUBLISHING_NONCE_TTL_MS |
300000 |
Nonce replay window |
ADMIN_TOKEN |
dev-admin-token |
Admin route token |
POSTHOG_KEY |
`` | Optional PostHog key |
FREE_TIER_MONTHLY_LIMIT |
100 |
Free tier request limit |
FREE_TIER_WINDOW_MS |
2592000000 |
Free tier window |
SUBDOMAINS_ENABLED |
true |
Enable subdomains |
SUBDOMAIN_MAX_LENGTH |
63 |
Max subdomain length |
SUBDOMAIN_MAX_PAGES |
100 |
Max pages per subdomain |
SUBDOMAIN_RESERVED_NAMES |
(see config) | Reserved names |
SUBDOMAIN_BASE_DOMAIN |
zenbin.org |
Base domain |
Ready-to-adapt publisher examples are included in examples/:
examples/node-publish.mjsexamples/python_publish.pyexamples/deno_publish.ts
Each example shows the same core flow:
- build the JSON body
- hash it into
Content-Digest - build the canonical signing string
- sign it with Ed25519
POSTit to ZenBin
npm run dev
npm run build
npm test
npm run typecheckMIT