Carriers

Occasionally your website will need to do something using privileged keys (e.g. submit a form, call third party APIs, etc) - carriers are small javascript or typescript functions that can be used to abstract these calls so you don't leak your keys. They "carry information" to your website, should you need to use information not available at build time or via simple scripting.

Carriers are serverless and are deployed automatically whenever your site builds. They run on the same edge as your site in v8 isolates so they're really fast, and they can store small amounts of information permanently if you need state.

Once deployed, a carrier is reachable at /carriers/<carrier-name> on your site. For example, a carrier in carriers/submit-form/ is served at https://yoursite.com/carriers/submit-form. A carrier can also run on a schedule, with no request at all.

Folder structure

Carriers live in a top-level carriers/ directory in your repo. Each subdirectory is a single carrier, and the directory name becomes the carrier's URL path:

carriers/
  submit-form/
    package.json        # names the carrier API version and any schedule; "main" selects the entry file, "dependencies" are installed
    package-lock.json   # required when the carrier has dependencies
    index.ts            # the carrier's entry file
    archival-objects.d.ts  # generated by npx archival-carrier-types — commit this
    .gitignore          # ignores generated carrier_* build files
  • The directory name (submit-form above) is the carrier name and the URL segment: /carriers/submit-form.
  • By default the entry file is index.ts, index.mts, index.js, or index.mjs — whichever exists. If you include a package.json, its main field selects the entry file instead (e.g. "main": "submit.ts").
  • During a build, archival writes generated wrapper and config files named carrier_* into the carrier directory. Add carrier_* to a .gitignore so these build artifacts aren't committed.

TypeScript carriers

A carrier may be written in TypeScript or JavaScript. TypeScript's types are stripped when the carrier is built, so there is no separate compile step to configure — name your entry file index.ts and it just works.

Types are removed rather than compiled, which is the rule Node applies to .ts files. That rules out the few TypeScript features that generate code: enum, namespace and constructor parameter properties. An import used only as a type must say import type. Setting erasableSyntaxOnly and verbatimModuleSyntax in the carrier's tsconfig.json makes your editor hold you to both.

Because npm init writes "main": "index.js" by default, a main that points at a file which was never emitted is resolved against every supported extension before giving up. In other words, "main": "index.js" next to an index.ts finds the TypeScript file rather than failing the deploy.

To get types for the carrier signature itself, add @archival/carrier as a dev dependency:

cd carriers/submit-form
npm install --save-dev @archival/carrier
import type { Carrier } from "@archival/carrier";

const carrier: Carrier = async (params, body, objects, site) => ({
  hello: params.get("name"),
  site: site.url,
});

export default carrier;

The package exports the whole contract, so you can name any part of it explicitly:

Type
Carrier<Objects> The handler itself — the default export of your entry file. The type parameter is an escape hatch if you'd rather name your objects type than rely on the generated augmentation.
CarrierScheduled<Objects> A scheduled run — the scheduled export of your entry file.
CarrierScheduledEvent A scheduled run's third argument: the cron it runs on and the scheduledTime it was due.
CarrierParams The first argument: a URLSearchParams.
CarrierBody The second argument: parsed JSON, a form body, text, or null.
CarrierObjects / SiteObjects The third argument: your site's objects.
CarrierSite The fourth argument: your site's url, uploads, email, sql, activitypub and webmentions.
CarrierUploads site.uploads: list() and get().
CarrierUpload One upload's body and metadata, as get() resolves it.
CarrierUploadEntry A { sha, filename } pair, as list() returns them.
CarrierEmail site.email: send().
CarrierSql site.sql: exec() and transaction().
CarrierSqlRow One row a query returns, keyed by column name.
CarrierActivityPub site.activitypub: account(), followers(), counts(), likes(), boosts(), replies() and setLocalLikes().
CarrierWebmentions site.webmentions: counts() and list().
CarrierReply One reply to a post, as replies() lists them.
CarrierEmailMessage What send() takes: from, to, subject, text, html and replyTo.
CarrierEmailReceipt What send() resolves to: the id of the send and the from it resolved to.
CarrierFormBody A parsed form submission — { [key: string]: string | Blob }.
CarrierJsonValue Any value that survives a JSON round trip.
CarrierResponse What a carrier may return.

The package is types-only at runtime, so it belongs in devDependencies — nothing from it ships with the deployed carrier.

Carrier API versions

What archival hands a carrier is versioned, so it can change without breaking a carrier written against an earlier version. A carrier names the version it's written against in its package.json:

{
  "type": "module",
  "archival": { "carrier": 2 },
  "devDependencies": { "@archival/carrier": "^1.0.0" }
}

Everything on this page describes version 2, which @archival/carrier@1 types. A carrier that names no version is version 1, and always will be, so a carrier written before versions existed keeps working exactly as it was written. A version that doesn't exist fails the deploy with a message naming the file.

Version 1 carriers, typed by @archival/carrier@0, receive three arguments, (params, body, objects), with SITE_URL, UPLOADS and EMAIL merged into objects over any object of the same name. To move one to version 2:

  1. Add "archival": { "carrier": 2 } to its package.json and install @archival/carrier@1.
  2. Take a fourth argument, site, and read site.url, site.uploads and site.email where you read objects.SITE_URL, objects.UPLOADS and objects.EMAIL.
  3. Run npx archival-carrier-types again.

objects then holds only your site's objects, so an object named SITE_URL, UPLOADS or EMAIL is no longer hidden.

How a carrier is built

A carrier is built the same way everywhere it runs: when your site deploys, in the preview in the Archival app, and under archival run. The build does three things, and nothing else:

  1. Installs the packages your package-lock.json names.
  2. Strips TypeScript's types from your files.
  3. Links your files and your dependencies as the ES modules they are.

Nothing is bundled, and no build script is run. A scripts.build in the carrier's package.json is ignored, so main must name a source file rather than something a build would have emitted.

That gives a carrier two rules:

  • ES modules only. Your files and every package you depend on must use import and export. A CommonJS file (require, module.exports, a .cjs extension) is refused, and so is a package that only publishes CommonJS.
  • Web APIs only. fetch, crypto.subtle, URL, TextEncoder, Response and the rest of the web platform are there. Node built-ins (node:fs, node:crypto, Buffer) are not, and importing one is refused.

Imports of your own files may name the file with or without its extension (./sheet or ./sheet.ts), and a .json file can be imported as its value.

When a carrier breaks one of these rules, the deploy fails with a message naming the file or package, and the preview and archival run answer the request with the same message.

Dependencies

A carrier can depend on npm packages. List them under dependencies in the carrier's package.json and commit its package-lock.json (or npm-shrinkwrap.json):

{
  "type": "module",
  "main": "index.ts",
  "dependencies": {
    "nanoid": "^5.0.0"
  },
  "archival": { "carrier": 2 },
  "devDependencies": {
    "@archival/carrier": "^1.0.0",
    "typescript": "^5.8.0"
  }
}

The lockfile is what gets installed: each package is downloaded from the tarball it records and checked against its hash, so the same files are installed wherever the carrier is built. Dependencies with no lockfile fail the build. The lockfile must be version 2 or later, which is what npm 7 and up write.

  • devDependencies are never installed, so that is where @archival/carrier, typescript and your test tools belong.
  • Install scripts do not run, and a package that needs one to work will not.
  • A package must publish ES modules that run on web APIs. Where a package offers both a Node build and a browser build, the browser build is the one a carrier gets.

Carrier request signature

A carrier answers requests with the async function its entry file exports as default, which receives four arguments:

export default async function (params, body, objects, site) {
  // ...your logic
}
  • params — a URLSearchParams built from the request's query string.
  • body — the parsed request body for POST and PUT requests (null for other methods). How it's parsed depends on the request's Content-Type:
    • application/json → a parsed JSON value
    • application/x-www-form-urlencoded, multipart/form-data, or application/form → an object of the form fields (file fields arrive as a Blob)
    • anything else → the raw request text
  • objects — your site's objects (see below).
  • site — a simple object for interacting with data: your site's url, uploads, email, sql, activitypub and webmentions (the last two when enabled).

The function's return value determines the HTTP response:

  • An object is serialized to a 200 JSON response (content-type: application/json).
  • A string is returned as a 200 text/plain response. If the string begins with redirect:, the remainder is used as a Location header and a 302 redirect is sent instead.
  • A Response is sent as-is, for when you need to set your own status, headers, or body — serving a file, for instance.
  • Throwing an error produces a 500 response containing the error message.
  • Returning anything else produces a 500 response.

A minimal carrier that validates a form post and redirects back to the site:

export default async function (params, body, objects, site) {
  if (!body) {
    throw new Error("Method Not Allowed");
  }
  // ...do something with the submitted fields...
  return "redirect:" + site.url + "?submitStatus=ok";
}

The objects and site arguments

objects is your site's whole object tree, so a carrier can read the same content your templates render without refetching or duplicating it.

export default async function (params, body, objects) {
  return {
    titles: objects.posts.map((post) => post.title),
    contact: objects.settings.contact,
    hero: objects.posts[0].hero?.url,
  };
}

The values match what a liquid template sees:

  • Objects backed by a directory (objects/posts/*.toml) arrive as an array, sorted the way archival sorts them. Objects backed by a single file (objects/settings.toml) arrive as a single value. An object with no files on disk is an empty array.
  • Unset fields are null, and child objects default to [].
  • Every object read from its own file also carries path and order.
  • File fields (image, video, audio, upload) carry their resolved url, alongside filename, sha, mime and display_type.
  • date fields are ISO 8601 strings, since JSON has no date type.

site holds what archival gives a carrier besides your objects:

  • site.url — the full URL of your site (e.g. https://yoursite.com). Use it to build absolute URLs for redirects and links rather than hard-coding your domain.
  • site.uploads — the files uploaded to your site, readable at request time. See Reading uploads.
  • site.email — sends mail from your site's own email addresses. See Sending email.
  • site.sql — your site's own SQLite database. See Storing data.
  • site.activitypub — your site's fediverse followers, and the likes, boosts and replies its posts have had. See Reading the fediverse.
  • site.webmentions — pages on other sites that link to yours, with their replies and likes. See Reading mentions from other sites.

A few things worth knowing:

  • Objects are embedded at deploy time, so a carrier sees a snapshot from the build it was deployed with, not live content. Publishing your site redeploys its carriers with fresh values. (site.uploads, site.email, site.sql, site.activitypub and site.webmentions act when the request runs.)
  • The tree is deeply frozen, so one request can't mutate what the next request sees.
  • Because they're embedded, there's an upper bound on how large a site's objects can be (5MB serialized). A site over that fails its carrier deploy rather than silently shipping a carrier with missing data.

Running on a schedule

A carrier can also run on its own, on a schedule — to send a digest, clear expired rows out of site.sql, or refresh something from another service. Name the schedule as a cron expression in the carrier's package.json, beside its carrier API version:

{
  "type": "module",
  "archival": { "carrier": 2, "schedule": "*/15 * * * *" },
  "devDependencies": { "@archival/carrier": "^1.3.0" }
}

and export scheduled from its entry file:

import type { CarrierScheduled } from "@archival/carrier";

export const scheduled: CarrierScheduled = async (objects, site, event) => {
  const due = await site.sql.exec<{ email: string }>(
    "SELECT email FROM reminders WHERE at <= ?",
    event.scheduledTime,
  );
  for (const { email } of due) {
    await site.email.send({
      to: email,
      subject: "Your reminder",
      text: "It's time.",
    });
  }
};

scheduled receives three arguments, and its return value is ignored:

  • objects — your site's objects, exactly as a request gets them.
  • site — the same url, uploads, email, sql, activitypub and webmentions a request gets.
  • event — the cron expression the run is on, and the scheduledTime it was due, in milliseconds since the epoch.

A carrier can export both a default function and scheduled, so the carrier that takes a sign-up can also send the reminders. One that exports only scheduled answers requests with a 404. Only archival starts a run: a request to the carrier's URL never calls scheduled.

Schedules

A schedule is a standard five-field crontab expression — minute, hour, day of the month, month and day of the week — read in UTC:

Schedule Runs
* * * * * Every minute.
*/15 * * * * Every 15 minutes, on the quarter hour.
0 * * * * At the top of every hour.
30 8 * * MON-FRI At 08:30 UTC on weekdays.
0 0 1 * * At midnight UTC on the first of each month.

@hourly, @daily, @weekly, @monthly and @yearly work too. When both the day of the month and the day of the week are restricted, a run happens on any day that matches either, as in standard cron.

Nothing runs more often than once a minute. A sixth field, for seconds, fails the deploy. So does a schedule that isn't a cron expression, one that can never run (0 0 30 2 *), one on a carrier written against version 1 of the carrier API, and one whose carrier doesn't export scheduled.

How runs happen

  • Each run counts as a request to your site, against the same allowance your visitors use. A carrier that runs every minute makes about 44,000 requests a month. With "Bill me for usage beyond my plan" turned off, a site that has used its requests stops running its schedules as well as serving pages, until the period resets.
  • A run happens at most once, and is never retried. One that throws, or that is missed, is not made up; the next one happens on schedule.
  • A schedule takes effect when your site deploys. Runs start at the first scheduled time after the deploy, and a deploy that leaves a carrier's schedule unchanged keeps the run it was waiting for. Removing the schedule, or the carrier, stops it at the next deploy.
  • Runs use your site's primary domain, so site.url is that domain's URL.
  • Every run is logged. It appears under the carrier in your site's carrier logs in the editor, with whatever the carrier logged, and the error when it throws.
  • Only your deployed site runs schedules. The preview in the Archival app and archival run never call scheduled.

Reading uploads

site.uploads reads the files you've uploaded to your site. Unlike objects it isn't a snapshot — it reads at request time, so a carrier sees files uploaded since it was deployed. Reads are scoped to your own site, and there is no way to write.

This is what lets a carrier put a file behind a check your site can't make on its own — a password, a signed link, a purchase — or serve one under a name that isn't known until the request arrives.

list() gives every file, as the name it was uploaded with and the content hash it's stored under:

await site.uploads.list();
// [{ sha: "31f4725e…", filename: "cover.png" }, …]

get() reads one, by name, and resolves to null when nothing matches:

const upload = await site.uploads.get("cover.png");

Names aren't unique — the same name can be uploaded more than once, under different hashes — so looking one up by name alone throws when it's ambiguous rather than guessing. Pass the hash to say which you meant, or pass a file field straight off objects, which already carries its own:

await site.uploads.get("cover.png", "31f4725e…");
await site.uploads.get(objects.settings.menu);

What you get back is the file's body and metadata: body, size, etag, httpEtag, arrayBuffer(), text(), json(), blob(), and writeHttpMetadata(headers), which copies the stored content type onto a Headers you're building.

Returning a Response is how you serve one back:

const carrier: Carrier = async (params, body, objects, site) => {
  if (params.get("password") !== objects.settings.download_password) {
    return "redirect:/login";
  }
  const upload = await site.uploads.get("private-menu.pdf");
  if (!upload) {
    return "redirect:/404";
  }
  const headers = new Headers();
  upload.writeHttpMetadata(headers);
  headers.set("etag", upload.httpEtag);
  return new Response(upload.body, { headers });
};

Bear in mind that a file which is already referenced by a published object is served from the CDN too, at its own url — reading it through a carrier doesn't hide it. Gating only means something for files nothing on your site links to.

Sending email

site.email.send sends mail from one of your site's own email addresses, so a carrier can confirm a form submission or tell you about an order from an address your visitors recognize. It needs a plan that includes email and at least one address on one of your site's domains — add those under Email in the site's settings in the editor.

const carrier: Carrier = async (params, body, objects, site) => {
  if (!body || typeof body.email !== "string") {
    throw new Error("Method Not Allowed");
  }
  await site.email.send({
    to: body.email,
    subject: "Thanks for getting in touch",
    text: "We got your message and will reply within a day.",
  });
  return "redirect:/thanks";
};
Field
from One of your site's addresses. Defaults to the first address on your site's primary domain. A copy of every message lands in that address's Sent folder.
to A single address, or up to ten.
subject The subject line.
text, html The body — at least one is required, and both may be given.
replyTo Where replies go, when not from.

send resolves once archival has accepted the message, with an id for its logs and the from it resolved to. Delivery happens afterwards; if it fails, the site's owner is emailed about it. send rejects when the message is not accepted: the site has no email, from is not one of its addresses, a recipient is malformed, or the site has used its hourly allowance (100 messages).

A few things worth knowing:

  • A domain whose mail is received by another service — Google Workspace, say — can still send. Its addresses are sending-only: nothing arrives in them, and a carrier can't send to an address on such a domain, since that mail would never reach the service that receives it.
  • site.email only works in a deployed carrier. In a preview or a local run, send throws.
  • A carrier is reachable by anyone, so treat what a request asks you to send with care. A to taken straight from an untrusted body sends mail to whoever the request names; the recipient cap and the hourly allowance limit how far that goes, but validating the request is what keeps your site from being used as a relay.

Storing data

site.sql is your site's own SQLite database. Every carrier on the site shares it, and it keeps what it holds between requests and deploys — so a carrier can permanently store small pieces of information that are live and interactive.

const carrier: Carrier = async (params, body, objects, site) => {
  await site.sql.exec(
    "CREATE TABLE IF NOT EXISTS signups (email TEXT PRIMARY KEY, at TEXT NOT NULL)",
  );
  await site.sql.exec(
    "INSERT INTO signups (email, at) VALUES (?, ?) ON CONFLICT DO NOTHING",
    body.email,
    new Date().toISOString(),
  );
  const [{ count }] = await site.sql.exec<{ count: number }>(
    "SELECT count(*) AS count FROM signups",
  );
  return { count };
};

exec(sql, ...params) runs one statement and resolves to its rows, each keyed by column name, binding params to the statement's ? placeholders in order. transaction(statements) runs a list of [sql, ...params] statements in order and resolves to each one's rows:

await site.sql.transaction([
  ["UPDATE stock SET count = count - 1 WHERE sku = ?", sku],
  ["INSERT INTO orders (sku, email) VALUES (?, ?)", sku, body.email],
]);

Every call is one transaction. If a statement fails, the call rejects with SQLite's error and nothing it did is kept.

Strings, numbers and null are stored as they are, booleans as 1 and 0, and bytes (Uint8Array, ArrayBuffer) as blobs, which read back as a Uint8Array.

Every plan includes, each billing period:

Usage Included Past it, at cost
Calls 360,000 $0.45 per 1,000,000
Rows read 360,000,000 $0.01 per 10,000,000
Rows written 360,000 $0.01 per 10,000
Storage 50 MB $0.02 per 100 MB a month

Usage past an allowance is added to your site's next invoice, as its own line. With "Bill me for usage beyond my plan" turned off in your site's settings, the database refuses instead once an allowance is spent, until the period resets: every call for calls and rows read, writes for rows written, and growth for storage. Your site's usage panel shows where each one stands. A scan reads every row it passes, so index the columns your queries filter on.

Whatever the billing, these stop a runaway carrier, and a call that would go past one rejects:

Ceiling
Calls 5,000 an hour.
Rows read 5,000,000 an hour.
Rows written 5,000 an hour. A call that would pass it is rolled back.
Size 500 MB. A call that would grow the database past it is rolled back; deleting rows always works.
One call 100 statements, and 5 MB of rows back.

A few things worth knowing:

  • In the preview in the Archival app, site.sql rejects. Under archival run it is a SQLite file on your machine, not your site's database; see Running carriers locally.
  • A carrier is reachable by anyone, so whatever a request can make it write, anyone can. Validate what you store, and bind values with ? rather than building SQL from a request.

Reading the fediverse

A site with an activitypub section is an account people follow from Mastodon, Threads and the rest of the fediverse, and its posts collect likes, boosts and replies there. Archival keeps all of it, and site.activitypub reads it, so a carrier can show a post's replies under it on your own site:

const carrier: Carrier = async (params, body, objects, site) => {
  const post = objects.post.find((p) => p.path === params.get("post"));
  if (!post) {
    return "redirect:/404";
  }
  const [counts] = await site.activitypub.counts([post]);
  const replies = await site.activitypub.replies(post, { limit: 50 });
  return { counts, replies: replies.items };
};

A post is one of your objects, or its path ("post/hello-world") — the same object you mapped under activitypub.objects.

Method Resolves to
account() Your site's own account, or null when it doesn't federate: { handle, id, url, followers, following, posts }. handle is what people search for to follow it (@blog@example.com), and the three counts are its followers, the accounts it follows that have accepted, and its published posts.
counts(posts) { likes, boosts, replies } for each of up to 100 posts, in the order given. replies counts the whole thread.
followers(page?) A page of { account, followedAt }, newest first.
likes(post, page?) A page of { account, at }, most recent first.
boosts(post, page?) A page of { account, at }, most recent first.
replies(post, page?) A page of replies in the order they arrived — every reply in the post's thread, including replies to replies.
setLocalLikes(post, likes) Nothing. Records how many likes the post has on your own site, as described below.

A page is { items, total, next }. total counts every item on every page, and passing next back as cursor reads the following page; it is null on the last one:

let page = await site.activitypub.followers({ limit: 100 });
const all = [...page.items];
while (page.next) {
  page = await site.activitypub.followers({ limit: 100, cursor: page.next });
  all.push(...page.items);
}

limit is up to 100, and 20 when you leave it out. Times (followedAt, at, published, updated) are ISO 8601 strings.

Counting your own likes

When your site has a like button of its own, the fediverse can count those likes too. Keep the count wherever your button stores it, and pass the whole of it to setLocalLikes each time it changes:

await site.activitypub.setLocalLikes(post, likes);

Mastodon and other servers show the post's fediverse likes plus yours the next time they fetch it. counts() and likes() still read only the fediverse's. A site that doesn't federate keeps nothing.

Accounts

Every follower, like, boost and reply carries the account it came from, as its own profile last described it:

Field
id The account's ActivityPub id, which never changes. Use it to tell accounts apart.
handle @name@host.
name The display name, as plain text.
url The account's profile page.
icon The avatar image's address.

Everything but id can be null, when the account's profile doesn't say. Profiles refresh whenever the account interacts with your site again.

Replies

Each reply is:

Field
id The reply's ActivityPub id.
url The reply's page on its author's server, or null.
inReplyTo The id of what it answers: your post, or another reply in the thread.
account Who wrote it.
content The reply as HTML (see below).
summary Its content warning, as plain text, or null.
sensitive Whether its author marked it sensitive.
attachments Images and other media: { url, mediaType, description }, with description as the alt text.
published When it was written.
updated When its author last edited it, or null.

Replies come in the order they arrived, so a thread reads top to bottom. To nest it, group them by inReplyTo: the ones whose inReplyTo isn't another reply's id answer the post itself.

content is reduced to paragraphs, line breaks, emphasis, lists, quotes, code and links before it is kept — what Mastodon keeps of a post from another server — so it can go into your page as HTML. Every link is an http or https address, marked rel="nofollow noopener noreferrer". name, handle and summary are plain text written by whoever owns the account, so escape them like any other text.

What is kept

  • Replies are kept only when they are public — what Mastodon calls public or unlisted. A followers-only reply or a direct message never reaches a carrier. A reply counts when it answers one of your published posts, or a reply already kept in that post's thread.
  • Edits, deletes and undos apply as they arrive. An edited reply reads as edited, a deleted one is gone, and taking back a like or a boost removes it. When an account is deleted, everything it did on your site goes with it.
  • Only your published posts collect likes and boosts. One that is no longer federated — removed, or its when field turned false — stops collecting new ones, but what it already has is kept, and comes back with it.
  • A follower can have only an id for a while. When Archival hasn't read a follower's profile yet, a later deploy fills it in, a few dozen followers at a time.

A few things worth knowing:

  • Reads happen when the request runs, so a reply shows up as soon as its server delivers it, without redeploying.
  • There is no moderation: any public reply from any account is kept. A carrier that shows replies can leave some out — for instance accounts or servers you list in a secret or a setting.
  • Showing who follows you is your choice to make. Your account itself publishes only how many followers it has, so someone following it may not expect to appear on your website.
  • A site that doesn't federate — including while its activitypub section is removed or its subscription is cancelled — and every preview and archival run, reads as having no account and nothing on any list. A carrier that shows replies works everywhere without special cases.
  • A method rejects, with the reason, when it is given something that isn't a post, more than 100 posts, or a limit over 100.

Reading mentions from other sites

A site with a webmention section is told when a page elsewhere links to one of its pages, and Archival keeps each mention once it has checked the link. site.webmentions reads them, so a carrier can show the replies and likes a page has collected from other sites:

const carrier: Carrier = async (params, body, objects, site) => {
  const post = objects.post.find((p) => p.path === params.get("post"));
  if (!post) {
    return "redirect:/404";
  }
  const [counts] = await site.webmentions.counts([post]);
  const replies = await site.webmentions.list(post, { kind: "reply" });
  return { counts, replies: replies.items };
};

A page is one of your objects, or a path on your site: "post/hello-world", "about", and "" or "/" for the home page. Mentions of any page your site serves are kept, not only of your objects.

Method Resolves to
counts(pages) { replies, likes, reposts, bookmarks, mentions } for each of up to 100 pages, in the order given.
list(page, options?) A page of mentions, newest first. options takes limit and cursor like the other lists, and kind to read only the replies, likes, reposts, bookmarks or plain mentions.

A page of results is { items, total, next }, read the way site.activitypub's lists are: total counts every item, next goes back as cursor for the following page, and limit is up to 100, 20 when you leave it out.

Mentions

Each mention is:

Field
source The address of the page that links to yours.
url The address the page gives for itself, when it does; otherwise source.
kind "reply", "like", "repost", "bookmark" or "mention", read from the page's microformats. A page that links to yours without saying why is a mention.
author { name, url, photo } when the page says who wrote it, or null. Any of the three can be null.
name The page's title, as plain text, or null.
content The page's text as HTML, or null.
published When the page says it was written, or null.
received When Archival first verified it.
updated When the sending site last re-sent it with a change, or null.

content is reduced the same way a fediverse reply's is — paragraphs, line breaks, emphasis, lists, quotes, code and links — so it can go into your page as HTML. name and the author's name are plain text written by whoever owns the other site, so escape them like any other text. url, author.url and author.photo are always https addresses.

What is kept

  • A mention counts only once its link is verified. Archival fetches the page that claims to link to yours and keeps the mention when it does, and when the page it names is one your site serves. A page sent again is checked again, so an edit that changes its kind or text shows up, and one that no longer links, or has gone, takes its mention with it.
  • One mention per page per sending page. The same page linking to /post/hello and /post/hello.html is one mention, and sending it again replaces what was kept.
  • Nothing is moderated. Any site can send a mention of any page. A carrier that shows mentions can leave some out — for instance sites you list in a secret or a setting.
  • A site that doesn't take mentions — one without a webmention section, or whose subscription is cancelled — and every preview and archival run, reads as one no mention has reached: zero counts and empty lists. A carrier that shows mentions works everywhere without special cases.
  • A method rejects, with the reason, when it is given something that isn't a page, more than 100 pages, a kind it doesn't know, or a limit over 100.

Typing your site's objects

Every site's objects are different, so the types for them are generated from your archival_objects.toml. From a carrier directory that has @archival/carrier installed:

npx archival-carrier-types

That runs archival types and writes an archival-objects.d.ts next to each carrier — a self-contained module declaring an ArchivalObjects interface, followed by the one block that wires it into the carrier signature:

declare module "@archival/carrier" {
  interface SiteObjects extends ArchivalObjects {}
}

Each carrier gets the block for the carrier API version its package.json names, and the generator refuses a carrier whose installed @archival/carrier types a different version than the one it names.

Commit those files. They contain no object values, only your schema, and committing them means your editor and CI work on a fresh clone with no extra setup.

After generating, objects is fully typed:

const carrier: Carrier = async (params, body, objects) => ({
  titles: objects.posts.map((post) => post.title), // (string | null)[]
  contact: objects.settings.contact,
  hero: objects.posts[0].hero?.url,
});

Until you generate, reading anything off objects is a compile error — deliberately, so a carrier can't silently read an object that isn't there.

The generator needs the archival binary on your PATH or in your node_modules (npm install --save-dev archival, or cargo install archival).

Flag
--check Don't write; exit non-zero if anything is out of date. For CI.
--carrier <name> Only generate for one carrier.
--carriers-dir <path> Carriers directory, if not carriers.
--site <path> Site root, if not an ancestor of the working directory.
--out <path> Write a single file here instead of one per carrier.
--archival <path> Path to the archival binary.

The output is deterministic, so it's safe to keep honest in CI:

npx archival-carrier-types --check

Reading secrets

Fields declared secret are stripped from template contexts, but carriers run on the server and do receive their values — that's the point of the type. They're typed as string | null like any other string field.

const carrier: Carrier = async (params, body, objects) => {
  const response = await fetch("https://api.example.com/send", {
    headers: { authorization: `Bearer ${objects.settings.api_key}` },
  });
  return { ok: response.ok };
};

A secret set through the archival editor is stored encrypted in your repo, and archival decrypts it when it deploys your carriers, so objects always holds the value you typed. A secret you write into an object file by hand is committed as plain text. See Secrets for when each applies.

Running carriers locally

archival run serves your carriers beside your site, so http://localhost:1024/carriers/submit-form answers the way https://yoursite.com/carriers/submit-form will once deployed. It needs Node 22.13 or later on your PATH, and nothing else to install or configure.

  • Carriers are built the way a deploy builds them. The same build runs on your machine, so a carrier that breaks one of its rules answers with the message the deploy would fail with, and the others keep working.
  • Changes are picked up as you save. Editing a carrier, or adding or removing one, takes effect on the next request. That includes the first carrier in a site that had none when archival run started. Editing your objects reaches your carriers without a restart.
  • Logs go to your terminal. Whatever a carrier logs is printed by archival run, prefixed with [carrier].

A few things differ from a deployed carrier:

  • site.url is the local server's address, so a carrier that fetches its own site reaches your machine. Pass --site-url to change it.
  • site.sql is a SQLite file named site.sqlite in your build directory, shared by every carrier and kept across restarts. It is not your deployed site's database, and it starts empty. A call is refused for the same reasons a deployed one is, but the hourly ceilings and plan allowances don't apply. Delete the file to start over.
  • site.uploads.list() returns the files your objects point to, rather than everything you've uploaded.
  • site.email.send throws.
  • site.activitypub reads as a site that doesn't federate.
  • site.webmentions reads as a site no mention has reached.
  • Schedules never run.
  • Secrets reach a carrier exactly as they're stored in your object files. One the editor encrypted arrives in its encrypted form, because only archival's hosting can decrypt it.

To run your site without its carriers, pass --no-carriers. To debug a carrier, pass --carriers-inspect to run your carriers under node --inspect and attach your debugger.