Tiles Protocol: ATProto

By sōm ⁂ (@hyl.st)
Published:

Tiles Protocol: ATProto Records

| Item | Value | | --- | --- | | Date | 2026-11-06 | | Status | Unofficial proposal. The module path and message names are not registered. | | Abstract | Supply ATProto records, query services, and submit record changes through the host. |

Contents

Introduction

A record-rendering tile should work in different hosts without its own account credentials or network integration. This [Tiles Protocol][protocols] module lets the host supply records and perform ATProto operations on the tile's behalf. The goal of this protocol is to enable embeddable widgets that render arbitrary lexicons and preserve interactions those lexicons expect. Where the underlying service supplies suitable XRPC queries, tiles can search for records and request social views or feeds. Further expansions would ideally include support for generic feeds if necessary.

The expected interaction is:

The tile owns presentation and interaction. The host owns account access, authorization, and transport. Record and query semantics come from their Lexicons; this document defines their use across the tile boundary.

Module

The host supplies /.well-known/web-tiles/atproto-records.js according to [Tiles Protocols][protocols]. The tile imports the module and checks for the functions it needs.

try {
  const records = await import('/.well-known/web-tiles/atproto-records.js');
  if (typeof records.addContextHandler === 'function') {
    records.addContextHandler(({record, error}) => {
      if (error) showError(error);
      else if (record) render(record.value);
    });
    records.listen({
      record: 'required',
      collections: ['at.margin.note']
    });
  }
} catch (error) {
  showUnavailable(error);
}

| Export | Purpose | Return value | | --- | --- | --- | | addContextHandler(handler) | Receive the displayed record and updates. | An unsubscribe function. | | listen({record, collections}) | Declare rendering requirements and signal readiness. | None. | | getAccount() | Identify the selected write account. | A Promise for {did} or null. | | getRecord({uri}) | Request a public record. | A Promise for a record. | | query({service, method, params}) | Query a public XRPC service. | A Promise for the endpoint's JSON response. | | writeRecord(operation) | Request a record change. | A Promise for a write result. |

Unsupported functions MUST be omitted. addContextHandler and listen MUST be supplied together or both omitted. A host that supplies writeRecord MUST also supply getAccount. Account, read, query, and write calls work independently of listen().

Context

Context is the record the host chose this tile to display. It is supplied without a getRecord() request. The tile registers a context handler, then calls listen() with its rendering requirements:

| record value | Host behavior | collections | | --- | --- | --- | | required | MUST supply a matching record or an error. | A nonempty array of collection [NSIDs][nsid]. | | optional | Supply a matching record when available; otherwise supply null. | A nonempty array of collection [NSIDs][nsid]. | | none | MUST NOT send record context. | MUST be omitted. |

These declarations make clear what the tile intends to render on startup. A search tile can use record: 'none' and make XRPC queries without an initial record. The tile supplies the service and query endpoint; a record Lexicon alone does not select an AppView.

For required and optional, the host MUST send one initial context. Repeated listen() calls MUST NOT change the declaration or repeat initial delivery.

| Context payload | Meaning | | --- | --- | | {record} | The content record, in the [getRecord response][get-record] format. | | {record: null} | No matching content record for an optional listener. | | {error} | The host could not supply the content record. See Results and errors. | | {uri, record: null} | A known deletion of the displayed record. |

The host SHOULD send later versions of the same record when it learns of changes. Updates use {record} with the same canonical URI. A temporary read failure MUST NOT be reported as deletion. The host does not promise every intermediate version or a continuously current record. The client MAY request an explicit refresh through getRecord().

Context crosses the boundary through structured cloning, as [Tiles Protocols][protocols] specifies. Removing a handler prevents further calls to it; late registration does not promise replay.

Account

getAccount() identifies the account that writes will affect. It returns {did}, or null if no account is selected.

| Condition | Outcome | | --- | --- | | An account is selected. | Return {did}. This does not grant write permission. | | No account is selected. | Return null. | | The host refuses identity disclosure. | Reject with denied. The host MUST also refuse writes. |

The tile includes this DID as repo in each write request. If the selected account changes before dispatch, the host refuses the request rather than using a different account. The host MAY choose not to disclose the user's DID, which ensures no writes will happen.

Reads

getRecord({uri}) requests one public record using the semantics of [com.atproto.repo.getRecord][get-record]. uri identifies a complete record under [AT URI Scheme][at-uri].

The host MAY return cached data. This call does not request a historical CID, guarantee the latest version, or require cache revalidation. An explicit refresh uses the same call.

Queries

query({service, method, params}) requests a public [XRPC query][xrpc]. Giving the tile the freedom to make read requests from the services it requires allows for more freedom in the shape of tiles. XRPC constrains requests reasonably to the Atmosphere.

| Field | Value | | --- | --- | | service | An HTTPS origin, with no credentials, path other than /, query, or fragment. | | method | The query endpoint's NSID, not a record collection or URL. | | params | Optional map of parameter names to strings, booleans, integers, or homogeneous arrays of those values, encoded according to [XRPC][xrpc]. Omit absent parameters; null is not a parameter value. |

Arrays use repeated parameter names. An empty array contributes no parameters.

Unknown request fields MUST be rejected. This restriction does not apply to parameter names. The host constructs GET /xrpc/{method} at service, with no request body. Procedures, subscriptions, arbitrary HTTP paths, and tile-supplied headers are not supported. The host MUST NOT attach account credentials, cookies, API keys, or service-authentication tokens. It MUST NOT follow redirects. Authenticated service queries are outside this specification.

The tile supplies any parameter defaults specified by that Lexicon. The host MAY refuse individual requests under its network security or resource policies, using denied. The host MUST NOT silently substitute another service or query endpoint.

const result = await records.query({
  service: 'https://constellation.microcosm.blue',
  method: 'blue.microcosm.links.getBacklinks',
  params: {
    subject: annotationUri,
    source: 'at.margin.reply:root.uri',
    limit: 30
  }
});

A successful query resolves with the endpoint's JSON response body, without reshaping it into records. The tile interprets results and pagination according to that endpoint, and MAY fetch returned record URIs through getRecord(). Subsequent pages use the same service and endpoint with the endpoint's pagination parameters. Browser hosts remain subject to CORS unless they provide another transport; XRPC does not require services to enable CORS.

A tile that does not need a displayed record can use listen({record: 'none'}) and make queries independently.

Writes

writeRecord(operation) requests one create, update, or delete operation. The host performs it through the selected account's authenticated repository API.

| Field | Value | | --- | --- | | repo | The selected account DID returned by getAccount(). | | action | create, update, or delete. | | collection | A collection [NSID][nsid]. | | rkey | A [record key][record-key]. Optional for create; required for update and delete. | | record | Required for create and update; absent for delete. Its $type MUST equal collection. | | swapRecord | The CID of the record version to change. Required for update and delete; absent for create. |

Unknown operation fields MUST be rejected. This restriction does not apply to fields inside record.

The host MUST use the following procedures:

| Action | Repository procedure | Arguments | | --- | --- | --- | | create | [createRecord][create-record] | repo, collection, record, optional rkey. | | update | [putRecord][put-record] | repo, collection, rkey, record, swapRecord. | | delete | [deleteRecord][delete-record] | repo, collection, rkey, swapRecord. |

An omitted create key MUST remain omitted so the repository can generate it. A create MUST NOT become an update when the key exists. Update and delete MUST use the supplied CID as a server-enforced precondition.

Before dispatch and each transport retry, the host MUST check the selected account and authorization for the repository, collection, and action. A different selected DID MUST produce account-changed, not a write to another account.

Multiple calls MAY be pending; each has its own Promise and result. Execution and completion order are not guaranteed. A client that requires sequential writes MUST await each result before making the next call.

Results and errors

Account, read, query, and write calls return Promises. A successful call resolves with the corresponding value below; a failed call rejects with {code, message?, status?, error?}. Context errors use the same error format in the handler's {error} payload.

Successful calls

| Call | Result | Meaning | | --- | --- | --- | | getAccount() | {did} or null | The selected write account, if any. | | getRecord() | {uri, value, cid?} | A record in the [getRecord response][get-record] format. | | query() | The endpoint's JSON response body. | The selected service returned a successful JSON response. | | writeRecord() with create or update | {uri, cid} | The authenticated repository API returned success for this request. | | writeRecord() with delete | {uri} | The authenticated repository API returned success; the host constructs the URI from the request. |

The host MUST check returned record identity before reporting write success. No additional read is required. Success does not promise permanent state or downstream indexing; see [Writing Data][writing].

Failed calls

Clients use code, not display text, to identify errors.

| Code | Meaning | | --- | --- | | unsupported | The exported function cannot supply this operation. | | invalid-request | Invalid arguments or a definite record-schema refusal. | | denied | Host policy or service authorization refuses the request. | | no-account | No write account is selected. | | account-changed | The selected account differs from repo. | | not-found | The read source did not supply the record; includes repository RecordNotFound. | | conflict | The repository refused the write precondition with InvalidSwap. | | authentication-required | The host cannot supply the authentication that the service requires. | | rejected | Another definite repository refusal. | | unavailable | The call could not complete. | | outcome-unknown | A write could have occurred, but neither success nor definite refusal is established. |

For queries, HTTP 401 maps to authentication-required, 403 to denied, 400 to invalid-request, and other unsuccessful HTTP responses to unavailable. A non-JSON successful response also produces unavailable. When an HTTP error response is received, the rejection MUST also include status (the HTTP status) and, when present in an XRPC error body, error (the endpoint's error name). These fields preserve endpoint-specific failures without adding protocol error codes; message MAY carry the service's error message.

These codes are exhaustive. outcome-unknown takes precedence over unavailable for ambiguously dispatched writes. The host MUST NOT automatically retry such a mutation. An unknown outcome does not establish that a replacement write is safe.

Web binding

Web runtimes use the action and payload convention in [Tiles Protocols][protocols]:

| Direction | Action | Payload | | --- | --- | --- | | Tile → host | tiles-protocol-up-atproto-records-ready | {record, collections?} from listen(). | | Host → tile | tiles-protocol-down-atproto-records-context | Context payload. | | Tile → host | tiles-protocol-up-atproto-records-account | {id} | | Tile → host | tiles-protocol-up-atproto-records-get | {id, uri} | | Tile → host | tiles-protocol-up-atproto-records-query | {id, service, method, params?} | | Tile → host | tiles-protocol-up-atproto-records-write | {id, operation} | | Host → tile | tiles-protocol-down-atproto-records-result | {id, result} or {id, error} |

| Binding rule | Requirement | | --- | --- | | Readiness | listen() sends ready after context reception is installed. | | Response reception | MUST be installed before each request is sent, independently of listen(). | | Request IDs | MUST be unique strings within a tile instance; each result uses its request ID. | | Completion | The host MUST return one result or error per valid call while communication remains available. | | Duplicate requests | The host MUST NOT execute the same ID more than once within that instance. | | Unexpected results | The module MUST ignore results for unknown or completed IDs. | | Channel identity | The host MUST authenticate requesting instances; the module MUST authenticate its host channel. Action names and IDs are not authentication. | | Instance replacement | Results and context MUST NOT be delivered to replacement instances. | | Timeouts | Calls MUST have finite timeouts; the host chooses their duration. | | Transport loss or disposal | Pending calls MUST fail if the tile can still execute. A write that could have occurred uses outcome-unknown unless a definite outcome is established. |

Host responsibilities

Hosts MUST preserve [Web Tiles][tiles] isolation and keep credentials outside tiles. Write authorization is independent of read access and module availability. Public reads can disclose information to the receiving server. Hosts MUST validate requests and limit data size, request rates, and simultaneous requests. Tile closure, access removal, or account changes MUST NOT transfer pending write authority.

References

[tiles]: https://dasl.ing/tiles.html [protocols]: https://dasl.ing/tiles-protocols.html [writing]: https://atproto.com/guides/writing-data [data-model]: https://atproto.com/specs/data-model [nsid]: https://atproto.com/specs/nsid [record-key]: https://atproto.com/specs/record-key [at-uri]: https://atproto.com/specs/at-uri-scheme [permissions]: https://atproto.com/specs/permission [xrpc]: https://atproto.com/specs/xrpc [get-record]: https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/repo/getRecord.json [create-record]: https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/repo/createRecord.json [put-record]: https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/repo/putRecord.json [delete-record]: https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/repo/deleteRecord.json [rfc2119]: https://www.rfc-editor.org/rfc/rfc2119 [rfc8174]: https://www.rfc-editor.org/rfc/rfc8174