Developers
Building with Web Tiles
What a tile is on AT, the tools that make, run and publish them, and how to put all of that in your own app.
The model
A tile is a bundle of web resources described by a MASL manifest in bundle mode: a name, a resources map from paths to content-addressed blobs with their HTTP headers (at least content-type), and the usual web-app-manifest extras (description, icons, screenshots, categories, theme_color, background_color, sizing). The / entry is what gets rendered. When it runs, a tile can only load what its manifest lists; the embedding app mediates everything else. The Web Tiles spec has the details, including the CSP that a tile execution context must enforce.
On AT, a tile is a record in the ing.dasl.masl collection of its author's repository. Each resource is uploaded as a blob, and the manifest's src entries are blob refs, so the PDS keeps the blobs alive:
{
"$type": "ing.dasl.masl",
"cid": "bafyrei…", // the DRISL (dag-cbor) CID of "tile"
"createdAt": "2026-10-02T15:26:26.697Z",
"tile": {
"name": "Cosmic Junkyard",
"description": "Dodge falling dice…",
"sizing": { "width": 720, "height": 520 },
"icons": [{ "src": "/icon.png" }],
"screenshots": [{ "src": "/shot.png" }],
"prev": { "$link": "bafyrei…" }, // optional: manifest CID of the version this replaces
"resources": {
"/": {
"src": { "$type": "blob", "ref": { "$link": "bafkrei…" }, "mimeType": "application/octet-stream", "size": 50720 },
"content-type": "text/html; charset=utf-8"
},
"/icon.png": { "src": { … }, "content-type": "image/png" }
}
}
}
- The record key is normally a TID; any valid record key works, and appmosphe.re's short IDs are
@handle/rkey. sizeis required on blob refs and must be right;mimeTypeis required by PDSs and need not matchcontent-type. The spec recommendsapplication/octet-stream.cidis the CID of thetileobject as DRISL (dag-cbor, codec0x71, sha-256). It is whatprevpoints at in a later version.- In a
.tilefile the same manifest is the CAR header (withversion: 1androots: []added) and the resources are the CAR blocks, linked with plain{ "$link": … }CIDs instead of blob refs.
Making tiles
A tile source directory is just index.html (it becomes /), a manifest.json with at least a name, and whatever else the page needs. Content types are guessed from file names; anything you put under resources in the manifest is kept.
atile (@dasl/tiles)
The command-line tool. It bundles a directory into a .tile, or publishes it to your repo with an app password.
npm install -g @dasl/tiles
atile bundle ./my-tile my-tile.tile # write a carball, no login needed
atile login you.example app-password # stored in the OS keychain
atile publish ./my-tile # upload blobs + write the record (fresh TID)
atile publish -s ./my-tile # stable id: reuse this directory's record key next time
atile publish --tid 3mwvoqidyqd24 ./my-tile # an explicit record key
atile delete at://did:plc:…/ing.dasl.masl/3mwvoqidyqd24
-s remembers the at:// URI per directory in ~/.atile/identifiers.json, so republishing overwrites the same record. atile does not set prev; add "prev": { "$link": "…" } to manifest.json yourself if you want the chain.
Blastile
Blastile is a desktop app (Deno Desktop) for working with tiles: open a .tile or an at:// URI, watch a source directory with live reload (the CAR is rebuilt on every change), and publish over AT OAuth. It shares ~/.atile/identifiers.json with atile, so a directory keeps the same record whichever tool publishes it. It also hosts its own tile server, so tiles run isolated on random *.localhost origins.
tilers
tilers follows RSS, Atom and JSON feeds and turns each new entry into a self-contained tile (article, styles, fonts, images), saved as .tile and optionally published to your repo.
This site
The publish page takes a .tile or a directory, lets you check and edit the manifest, pick the record key (a fresh TID, your own, or an existing tile to update with prev set), previews the tile, and writes it to your repo from the browser.
Running tiles in your app
Tiles must run in an origin of their own with the spec's CSP sandbox, which the browser can't fake in-page. @dasl/tile-loader gives you a mothership on your page, which mints an iframe on a random subdomain of a load domain running @dasl/tile-server; the server serves the shuttle runtime with the right headers, and the shuttle asks the mothership for the tile's resources over postMessage. The tile never touches the network itself.
npm install @dasl/tile-loader
import { TileMothership } from '@dasl/tile-loader';
import { ATTileLoader } from '@dasl/tile-loader/at';
const ship = new TileMothership({ loadDomain: 'load.webtil.es' }); // or your own
ship.init(); // once per page
ship.addLoader(new ATTileLoader());
const tile = await ship.loadTile('at://did:plc:…/ing.dasl.masl/3mwvoqidyqd24');
document.body.append(await tile.renderCard()); // name, icon, screenshot; runs on click
document.body.append(tile.renderContent(480)); // or run it now, in a 480px-high iframe
tile.manifest.sizing; // the size the tile asked for
ATTileLoadertakesat://did:plc:…/ing.dasl.masl/<rkey>; resolve handles to DIDs first (com.atproto.identity.resolveHandle).did:webis not supported yet.@dasl/tile-loader/carloads.tilefiles over HTTP;@dasl/tile-loader/memoryruns a manifest whosesrcentries are bytes or strings you already hold (that is how the publish page previews before uploading).- Don't add a
sandboxattribute to the iframe yourself: isolation comes from the load domain's CSP and the random origin, and the attribute would break the shuttle. - The default load domain is
load.webtil.es; appmosphe.re usesload.webtil.es. Your own is one Express app plus wildcard DNS and TLS:
npm install @dasl/tile-server
import express from 'express';
import { createTileLoadingRouter } from '@dasl/tile-server';
const app = express();
app.set('trust proxy', 'loopback');
app.use(createTileLoadingRouter('tiles.example')); // answers load.tiles.example and *.tiles.example
app.listen(1503);
Every tile load is a new hostname, so the certificate has to be a wildcard (DNS-01), not per-host on-demand issuance. Tile-server 2.1 also ships /.well-known/web-tiles/data.js, a tiny module tiles can import to exchange data with the host through the shuttle (listen(), sendData(), addDataHandler()); the host listens for tiles-protocol-up-* messages on window and posts tiles-protocol-down-* ones to the shuttle iframe.
Publishing from your app
There is no publish API in the @dasl packages yet; it is a short sequence of XRPC calls against the author's PDS, which the publish page implements in the browser:
- Compute each resource's CID (CIDv1, raw codec
0x55, sha-256;@atcute/cid'screate(CODEC_RAW, bytes)). Resources with the same bytes share a CID. com.atproto.repo.uploadBlobeach one withContent-Type: application/octet-stream, and check that the PDS returns the same CID.- Build the manifest with
src: { "$type": "blob", "ref": { "$link": cid }, "mimeType": "application/octet-stream", "size": bytes.length }and acontent-typeper resource; setprevto the previous manifest CID when you are replacing a tile. cid= the DRISL CID of thattileobject (@atcute/cbor'sencode, thencreate(CODEC_DCBOR, bytes)). Encoding{ "$link": … }yields CBOR tag 42, as it must.com.atproto.repo.putRecordwithcollection: "ing.dasl.masl", your record key, and{ $type, cid, tile, createdAt }.
With OAuth, ask for atproto repo:ing.dasl.masl blob:*/*: the repo: scope covers the collection, blob:*/* the uploads. Then POST https://appmosphe.re/api/index with {"actor": did} so the tile shows up here within seconds rather than on the next relay sweep.
Finding tiles
- This site:
GET /api/tiles?page=N,GET /api/search?q=…,GET /api/tiles/:actor/:rkey(full record included),GET /api/authors,GET /api/authors/:actor,GET /api/stats. All JSON, no auth, CORS-free from the same origin only. - Your own index: run Tap with
TAP_SIGNAL_COLLECTION=ing.dasl.masl TAP_COLLECTION_FILTERS=ing.dasl.masl; it tracks every repo with a tile, verifies and backfills, and streams the records. That is what appmosphe.re does. - One-off:
com.atproto.sync.listReposByCollection?collection=ing.dasl.maslon a relay lists the repos;com.atproto.repo.listRecordson each PDS lists the tiles.