# wisp.place
wisp.place is a static site host for atproto. a site is stored in the user's repo as records and blobs:
- file bytes are uploaded to the user's pds as blobs
- `place.wisp.fs` is the main site manifest record
- `place.wisp.subfs` records hold extra filesystem subtrees when a site is too large for one manifest
- `place.wisp.settings` is optional per-site routing/header configuration
- wisp's service xrpcs manage domains, site metadata, and webhook signing secrets
public sites are served from:
- `https://sites.wisp.place/{handle-or-did}/{site-rkey}`
- claimed `*.wisp.place` subdomains
- verified custom domains
this guide assumes you already know atproto concepts like dids, pdss, records, blobs, at-uris, lexicons, service auth, and xrpc.
## quick use with wispctl
install:
```bash
npm install -g wispctl@latest
```
`wispctl` uses oauth by default. for ci/headless use, pass an app password with `--password`.
global option:
```bash
wispctl --quiet ...
```
`--quiet` suppresses progress output and is useful for agents and ci.
### deploy a site
```bash
wispctl deploy alice.example.com --path ./dist --site my-site
```
`deploy` is also the default command:
```bash
wispctl alice.example.com --path ./dist --site my-site
```
common deploy options:
```bash
wispctl deploy [handle] \
--path ./dist \
--site my-site \
--directory \
--spa \
--concurrency 3 \
--force-gzip \
--password "$APP_PASSWORD" \
--db ./state.sqlite \
--yes
```
deploy behavior:
- `--path` is the local directory to publish.
- `--site` is the `place.wisp.fs` record key and public site name. it defaults to the directory basename.
- site names are lowercased and must fit atproto record-key-ish characters: `[a-zA-Z0-9._~:-]{1,512}`.
- files are scanned recursively, excluding defaults plus `.wispignore`.
- files are usually gzip-compressed when useful, uploaded as `application/octet-stream`, and served later with the original `mimeType`.
- unchanged files reuse existing blob refs by cid.
- large manifests are split into `place.wisp.subfs` records automatically.
- `--directory` writes `place.wisp.settings` with directory listing enabled.
- `--spa` writes `place.wisp.settings` with `spaMode: "index.html"`.
after deploy, the site is available at:
```text
https://sites.wisp.place/{handle-or-did}/{site}
```
### pull a site
```bash
wispctl pull alice.example.com --site my-site --path ./my-site
```
pull reads the `place.wisp.fs` record, expands any `place.wisp.subfs` records, fetches blobs with `com.atproto.sync.getBlob`, decodes/decompresses as needed, and writes local files.
### serve locally
```bash
wispctl serve alice.example.com --site my-site --path .wisp-serve --port 8080
```
optional routing modes:
```bash
wispctl serve alice.example.com --site my-site --spa index.html
wispctl serve alice.example.com --site my-site --directory-listing
```
serve downloads/caches the site locally, serves it over http, and watches the firehose for `place.wisp.fs` and `place.wisp.settings` changes.
### domains
claim a custom domain:
```bash
wispctl domain claim alice.example.com --domain example.com --site my-site
```
claim a wisp subdomain:
```bash
wispctl domain claim-subdomain alice.example.com --subdomain alice --site my-site
```
check status:
```bash
wispctl domain status alice.example.com --domain example.com
```
map a domain to a site:
```bash
wispctl domain add-site alice.example.com --domain example.com --site my-site
```
run custom-domain dns verification:
```bash
wispctl domain verify alice.example.com --domain example.com
```
delete a domain:
```bash
wispctl domain delete alice.example.com --domain example.com
```
notes:
- `domain` also has alias `domains`.
- `claim-subdomain` takes only the label, for example `alice`, not `alice.wisp.place`.
- custom domain claim returns dns instructions:
- txt name: `_wisp.{domain}`
- txt value: caller did
- cname target: `{challengeId}.dns.wisp.place`
- wisp subdomains are verified immediately.
- custom domains must pass dns verification.
### list and delete sites
```bash
wispctl list domains alice.example.com
wispctl list sites alice.example.com
wispctl site delete alice.example.com --site my-site --yes
```
most domain/list/site commands support:
```bash
--json # raw json output
--password <password> # app password
--db <path> # oauth session db
--service <did:...> # service did, default did:web:wisp.place
```
`site delete` removes wisp service metadata and unmaps domains. it should not be interpreted as deleting every blob from the user's pds.
### login/logout
```bash
wispctl login alice.example.com
wispctl logout
wispctl logout --all
```
`login` stores an oauth session for the current directory. `logout` clears the current directory session or all sessions.
## how records model a site
a wisp site is a virtual filesystem tree.
`place.wisp.fs` is the root manifest:
- one record equals one site
- collection: `place.wisp.fs`
- record key: the site name, for example `my-site`
- required `site` field should match the record key
- required `root` field is a directory node
- file nodes point at pds blobs
- subfs nodes point at `place.wisp.subfs` records by at-uri
`place.wisp.subfs` is a subtree:
- collection: `place.wisp.subfs`
- no `site` field
- stores a directory root
- can reference other subfs records
- used to keep large sites under record size limits
`place.wisp.settings` configures serving behavior:
- collection: `place.wisp.settings`
- record key: same as the site rkey
- optional. if absent, wisp uses default static file behavior
file content handling:
- blob bytes may be gzip-compressed when `encoding: "gzip"`
- blob bytes may be base64 text when `base64: true`
- when both are present, decode base64 first, then gunzip
- serve the response using the node's `mimeType` when present
subfs expansion:
- in `place.wisp.fs#subfs`, `flat` defaults to true
- `flat: true` means merge the referenced subfs root entries into the parent directory
- `flat: false` means place the referenced entries in a directory named after the entry
- in `place.wisp.subfs#subfs`, there is no `flat`; nested subfs references are treated as merged subtrees
## typescript interfaces
these are the practical shapes agents usually need.
```ts
// place.wisp.fs
interface FsMain {
$type: 'place.wisp.fs'
site: string
root: FsDirectory
fileCount?: number
createdAt: string
}
interface FsFile {
$type?: 'place.wisp.fs#file'
type: 'file'
blob: BlobRef
encoding?: 'gzip'
mimeType?: string
base64?: boolean
}
interface FsDirectory {
$type?: 'place.wisp.fs#directory'
type: 'directory'
entries: FsEntry[]
}
interface FsEntry {
$type?: 'place.wisp.fs#entry'
name: string
node: FsFile | FsDirectory | FsSubfs | { $type: string }
}
interface FsSubfs {
$type?: 'place.wisp.fs#subfs'
type: 'subfs'
subject: string // at-uri to place.wisp.subfs
flat?: boolean
}
```
```ts
// place.wisp.subfs
interface SubfsMain {
$type: 'place.wisp.subfs'
root: SubfsDirectory
fileCount?: number
createdAt: string
}
interface SubfsFile {
$type?: 'place.wisp.subfs#file'
type: 'file'
blob: BlobRef
encoding?: 'gzip'
mimeType?: string
base64?: boolean
}
interface SubfsDirectory {
$type?: 'place.wisp.subfs#directory'
type: 'directory'
entries: SubfsEntry[]
}
interface SubfsEntry {
$type?: 'place.wisp.subfs#entry'
name: string
node: SubfsFile | SubfsDirectory | SubfsSubfs | { $type: string }
}
interface SubfsSubfs {
$type?: 'place.wisp.subfs#subfs'
type: 'subfs'
subject: string // at-uri to another place.wisp.subfs
}
```
```ts
// place.wisp.settings
interface SettingsMain {
$type: 'place.wisp.settings'
directoryListing?: boolean
spaMode?: string
custom404?: string
indexFiles?: string[]
cleanUrls?: boolean
headers?: CustomHeader[]
}
interface CustomHeader {
$type?: 'place.wisp.settings#customHeader'
name: string
value: string
path?: string
}
```
## raw lexicons
### place.wisp.fs
```json
{
"lexicon": 1,
"id": "place.wisp.fs",
"defs": {
"main": {
"type": "record",
"description": "Virtual filesystem manifest for a Wisp site",
"record": {
"type": "object",
"required": ["site", "root", "createdAt"],
"properties": {
"site": { "type": "string" },
"root": { "type": "ref", "ref": "#directory" },
"fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"createdAt": { "type": "string", "format": "datetime" }
}
}
},
"file": {
"type": "object",
"required": ["type", "blob"],
"properties": {
"type": { "type": "string", "const": "file" },
"blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000000, "description": "Content blob ref" },
"encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" },
"mimeType": { "type": "string", "description": "Original MIME type before compression" },
"base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" }
}
},
"directory": {
"type": "object",
"required": ["type", "entries"],
"properties": {
"type": { "type": "string", "const": "directory" },
"entries": { "type": "array", "maxLength": 500, "items": { "type": "ref", "ref": "#entry" } }
}
},
"entry": {
"type": "object",
"required": ["name", "node"],
"properties": {
"name": { "type": "string", "maxLength": 255 },
"node": { "type": "union", "refs": ["#file", "#directory", "#subfs"] }
}
},
"subfs": {
"type": "object",
"required": ["type", "subject"],
"properties": {
"type": { "type": "string", "const": "subfs" },
"subject": { "type": "string", "format": "at-uri", "description": "AT-URI pointing to a place.wisp.subfs record containing this subtree." },
"flat": { "type": "boolean", "description": "If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure." }
}
}
}
}
```
### place.wisp.subfs
```json
{
"lexicon": 1,
"id": "place.wisp.subfs",
"defs": {
"main": {
"type": "record",
"description": "Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure.",
"record": {
"type": "object",
"required": ["root", "createdAt"],
"properties": {
"root": { "type": "ref", "ref": "#directory" },
"fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"createdAt": { "type": "string", "format": "datetime" }
}
}
},
"file": {
"type": "object",
"required": ["type", "blob"],
"properties": {
"type": { "type": "string", "const": "file" },
"blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000000, "description": "Content blob ref" },
"encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" },
"mimeType": { "type": "string", "description": "Original MIME type before compression" },
"base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" }
}
},
"directory": {
"type": "object",
"required": ["type", "entries"],
"properties": {
"type": { "type": "string", "const": "directory" },
"entries": { "type": "array", "maxLength": 500, "items": { "type": "ref", "ref": "#entry" } }
}
},
"entry": {
"type": "object",
"required": ["name", "node"],
"properties": {
"name": { "type": "string", "maxLength": 255 },
"node": { "type": "union", "refs": ["#file", "#directory", "#subfs"] }
}
},
"subfs": {
"type": "object",
"required": ["type", "subject"],
"properties": {
"type": { "type": "string", "const": "subfs" },
"subject": { "type": "string", "format": "at-uri", "description": "AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures." }
}
}
}
}
```
### place.wisp.settings
```json
{
"lexicon": 1,
"id": "place.wisp.settings",
"defs": {
"main": {
"type": "record",
"description": "Configuration settings for a static site hosted on wisp.place",
"key": "any",
"record": {
"type": "object",
"properties": {
"directoryListing": { "type": "boolean", "description": "Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.", "default": false },
"spaMode": { "type": "string", "description": "File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.", "maxLength": 500 },
"custom404": { "type": "string", "description": "Custom 404 error page file path. Incompatible with directoryListing and spaMode.", "maxLength": 500 },
"indexFiles": { "type": "array", "description": "Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.", "items": { "type": "string", "maxLength": 255 }, "maxLength": 10 },
"cleanUrls": { "type": "boolean", "description": "Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.", "default": false },
"headers": { "type": "array", "description": "Custom HTTP headers to set on responses", "items": { "type": "ref", "ref": "#customHeader" }, "maxLength": 50 }
}
}
},
"customHeader": {
"type": "object",
"description": "Custom HTTP header configuration",
"required": ["name", "value"],
"properties": {
"name": { "type": "string", "description": "HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')", "maxLength": 100 },
"value": { "type": "string", "description": "HTTP header value", "maxLength": 1000 },
"path": { "type": "string", "description": "Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.", "maxLength": 500 }
}
}
}
}
```
## settings behavior
`directoryListing`: if a url resolves to a directory with no index file, wisp renders a generated directory listing instead of 404.
`spaMode`: path to a fallback file, usually `index.html`, used for single page app routing when a request does not match a file.
`custom404`: path to a file used as the 404 response body.
`indexFiles`: ordered filenames to try for directory requests. default behavior is `["index.html"]`.
`cleanUrls`: lets paths like `/about` try `/about.html` and `/about/index.html`.
`headers`: custom response headers. if `path` is omitted, the header applies globally. if `path` is present, it is a glob such as `*.html` or `/assets/*`.
`directoryListing`, `spaMode`, and `custom404` are intended as mutually exclusive fallback modes.
## service xrpc endpoints
wisp serves service xrpcs at:
```text
https://wisp.place/xrpc/{nsid}
```
authenticated endpoints require a service-auth jwt in:
```http
Authorization: Bearer <token>
```
the token must be scoped to the called nsid via the `lxm` claim. `place.wisp.v2.domain.getStatus` and `place.wisp.v2.site.getDomains` are public; `getStatus` returns more ownership-sensitive information when authenticated.
the server accepts canonical nsids plus some aliases:
- `place.wisp.v2.domain.add-site`, `place.wisp.v2.domain.addsite`
- `place.wisp.v2.domain.claim-subdomain`, `place.wisp.v2.domain.claimsubdomain`
- `place.wisp.v2.domain.get-list`, `place.wisp.v2.domain.getlist`
- `place.wisp.v2.domain.get-status`, `place.wisp.v2.domain.getstatus`
- `place.wisp.v2.site.delete-site`, `place.wisp.v2.site.deletesite`
- `place.wisp.v2.site.get-domains`, `place.wisp.v2.site.getdomains`
- `place.wisp.v2.site.get-list`, `place.wisp.v2.site.getlist`
### domain xrpcs
`place.wisp.v2.domain.getStatus` query, public/optional auth:
- params: `{ domain: string }`
- output: `{ domain, status, kind?, verified?, siteRkey?, lastCheckedAt?, lastError? }`
- `status`: `unclaimed | pendingVerification | verified | alreadyClaimed`
- `kind`: `wisp | custom`
`place.wisp.v2.domain.getList` query, auth required:
- output: `{ domains: DomainSummary[] }`
- `DomainSummary`: `{ domain, kind, status, verified, siteRkey?, lastCheckedAt? }`
- errors: `AuthenticationRequired`, `InvalidRequest`
`place.wisp.v2.domain.claimSubdomain` procedure, auth required:
- input: `{ handle: string, siteRkey?: string }`
- `handle` is only the `*.wisp.place` label
- output: `{ domain, kind: "wisp", status: "verified" | "alreadyClaimed", siteRkey? }`
- errors: `AuthenticationRequired`, `InvalidDomain`, `AlreadyClaimed`, `DomainLimitReached`, `RateLimitExceeded`
`place.wisp.v2.domain.claim` procedure, auth required:
- input: `{ domain: string, siteRkey?: string }`
- custom domains only
- output: `{ domain, kind: "custom", status, challengeId?, txtName?, txtValue?, cnameTarget?, siteRkey? }`
- `status`: `alreadyClaimed | pendingVerification | verified`
- errors: `AuthenticationRequired`, `InvalidDomain`, `AlreadyClaimed`, `DomainLimitReached`, `RateLimitExceeded`
`place.wisp.v2.domain.addSite` procedure, auth required:
- input: `{ domain: string, siteRkey: string }`
- caller must own both the domain and site
- output: `{ domain, kind, status, siteRkey, mapped: true }`
- errors: `AuthenticationRequired`, `InvalidDomain`, `InvalidRequest`, `NotFound`
`place.wisp.v2.domain.verify` procedure, auth required:
- input: `{ domain: string }`
- custom domains only
- output: `{ domain, kind: "custom", status, verified, error?, warning?, txtFound?, cnameFound? }`
- errors: `AuthenticationRequired`, `InvalidDomain`, `NotFound`
`place.wisp.v2.domain.delete` procedure, auth required:
- input: `{ domain: string }`
- output: `{ domain, deleted: true }`
- errors: `AuthenticationRequired`, `InvalidDomain`, `NotFound`
### site xrpcs
`place.wisp.v2.site.getList` query, auth required:
- output: `{ sites: SiteSummary[] }`
- `SiteSummary`: `{ siteRkey, displayName?, createdAt?, updatedAt?, domains }`
- `domains`: `{ domain, kind, status, verified }[]`
- errors: `AuthenticationRequired`
`place.wisp.v2.site.getDomains` query, public:
- params: `{ did: string, rkey: string }`
- output: `{ domains: SiteDomain[] }`
- `SiteDomain`: `{ domain, kind, status, verified }`
`place.wisp.v2.site.delete` procedure, auth required:
- input: `{ siteRkey: string }`
- output: `{ siteRkey, deleted: true, unmappedDomains }`
- `unmappedDomains`: `{ domain, kind, status }[]`
- errors: `AuthenticationRequired`, `InvalidRequest`, `NotFound`
### signing secret xrpcs
these manage server-side webhook signing secrets. tokens are returned only on create/rotate, never on list, and are stored as encrypted server-side envelopes. self-hosters must configure the webhook secret encryption key on both the main and delivery services; existing backups with historic plaintext are not retroactively protected.
`place.wisp.v2.secret.create` procedure, auth required:
- input: `{ name: string }`
- output: `{ name, token, createdAt }`
- errors: `AuthenticationRequired`, `InvalidRequest`, `AlreadyExists`
`place.wisp.v2.secret.list` query, auth required:
- output: `{ secrets: { name, createdAt, lastRotatedAt? }[] }`
- errors: `AuthenticationRequired`
`place.wisp.v2.secret.rotate` procedure, auth required:
- input: `{ name: string }`
- output: `{ name, token, rotatedAt }`
- errors: `AuthenticationRequired`, `NotFound`
`place.wisp.v2.secret.delete` procedure, auth required:
- input: `{ name: string }`
- output: empty object/response
- errors: `AuthenticationRequired`, `NotFound`
## atproto calls an agent may use directly
to publish without `wispctl`, an atproto-aware agent generally does this:
1. upload processed file bytes with `com.atproto.repo.uploadBlob`
2. build a `place.wisp.fs` tree containing blob refs
3. if needed, create `place.wisp.subfs` records and reference them from the fs tree
4. write the main record with `com.atproto.repo.putRecord`, collection `place.wisp.fs`, rkey = site name
5. optionally write `place.wisp.settings`, same rkey as the site
6. optionally call wisp service xrpcs to claim/map domains
to read a site:
1. fetch `place.wisp.fs` with `com.atproto.repo.getRecord`
2. recursively fetch any referenced `place.wisp.subfs` records
3. expand the virtual filesystem
4. fetch blobs with `com.atproto.sync.getBlob`
5. decode base64 if `base64: true`
6. gunzip if `encoding: "gzip"`
7. serve/write bytes using `mimeType`
important: the public hosting/caching service observes repo changes asynchronously. a freshly written record may take a moment to be reflected at the public url.