AT Functions treats the AT Protocol as a code registry and a Fastify service as a WASM execution runtime. Functions are stored as signed records on AT Proto, fetched on demand, and executed in a sandboxed WebAssembly environment.
pure-v1 / host-v1: ABI-level execution modes
component-v1: WASI Component Model + WIT typed interfaces
sequenceDiagram
actor Dev as Developer
participant ATP as AT Protocol
participant FS as Fastify Service
participant WM as WASM Runtime
Note over Dev,ATP: Registration (one-time)
Dev->>ATP: uploadBlob(function.wasm)
ATP-->>Dev: blob CID
Dev->>ATP: putRecord(at.functions.metadata, { mode, code: blobCID, limits })
ATP-->>Dev: at://did/at.functions.metadata/rkey
Note over Dev,WM: Invocation
Dev->>FS: POST /xrpc/at.functions.run<br/>{ function: at://..., input: {...} }
FS->>ATP: getRecord(at://...)
ATP-->>FS: { mode, code, maxMemoryMb, maxDurationMs }
FS->>ATP: getBlob(did, blobCID)
ATP-->>FS: function.wasm bytes
FS->>WM: WebAssembly.compile + instantiate
FS->>WM: alloc(inputLen) → ptr
FS->>WM: run(ptr, len) → packedPtrLen
WM-->>FS: output bytes (UTF-8 JSON)
FS-->>Dev: { ok: true, output: {...}, durationMs }
flowchart LR
subgraph ATP ["AT Protocol (registry)"]
R[("at.functions.metadata\nrecord")]
B[("WASM blob")]
R -- "ref → CID" --> B
end
subgraph FS ["Fastify Service (runtime)"]
direction TB
EP["POST /xrpc/at.functions.run"]
EP --> PV{"mode?"}
PV -- "pure-v1" --> PE["executePure\n(in-process)"]
PV -- "host-v1" --> HE["executeHost\n(worker thread)"]
HE <-- "Atomics +\nSharedArrayBuffer" --> HI["host imports\nread-only AT Proto"]
end
Client -->|"{ function: at://...\n input: {...} }"| EP
EP -->|"getRecord\ngetBlob"| ATP
PE --> Out["{ ok, output, durationMs }"]
HE --> Out
Out --> Client
| Mode | WASI | Host I/O | Deterministic |
|---|---|---|---|
pure-v1 |
✗ | ✗ | ✓ |
host-v1 |
✗ | Read-only AT Proto | mostly ✓ |
component-v1 |
✗ | Typed WIT imports | mostly ✓ |
component-v1 uses the WASI Component Model and WIT (WebAssembly Interface Types) instead of the raw pointer/length ABI used by pure-v1 and host-v1. It is the long-term direction for AT Functions.
Why it's better:
- Typed interfaces defined in WIT, so no manual ptr/len packing or JSON envelope wrapping for host calls.
- Language-agnostic: any language with a WIT bindgen (Rust, Go, C, Python, …) can implement the interface.
- The host contract is explicit and machine-verifiable, not just a convention.
Why WASI filesystem is NOT used: This is intentional. Only typed AT Proto capabilities (atfunc:runtime/atproto) are exposed to the component. No general-purpose WASI interfaces are imported, keeping the sandbox surface minimal.
Status: Currently experimental in this POC. jco programmatic transpilation is used at runtime (no ahead-of-time step on the server).
Requirements:
wasmtime(for local testing /wasm-tools)wasm-tools: to wrap awasm32-wasip1binary into a component- Rust
wasm32-wasip1target:rustup target add wasm32-wasip1
Compiling the component-rust example:
cd examples/component-rust
# Build the core WASM module targeting WASI P1
cargo build --target wasm32-wasip1 --release
# Wrap it into a WASI Component Model component
wasm-tools component new \
target/wasm32-wasip1/release/component_lister.wasm \
-o component_lister.component.wasmInvoking:
pnpm exec tsx scripts/invoke.ts \
--function "at://did:plc:yourDID/at.functions.metadata/component-lister-v1" \
--input '{"repo":"did:plc:yourDID","collection":"app.bsky.feed.post","limit":5}'Your WASM module must export:
memory - linear memory
alloc(len: i32) -> i32 - allocate len bytes, return ptr
run(ptr: i32, len: i32) -> i64 - execute; packed return: (ptr << 32 | len)
dealloc(ptr: i32, len: i32) - optional: free output buffer
Input/output are UTF-8 JSON. The host calls alloc(input.len), writes input bytes, calls run(ptr, len), then reads len output bytes from the returned pointer.
Available under the "host" namespace:
host_read_record(ptr: i32, len: i32) -> i64
input: { atUri: string }
output: { ok: bool, record?: unknown, cid?: string, error?: string }
host_read_blob(ptr: i32, len: i32) -> i64
input: { cid: string, repo?: string }
output: { ok: bool, mimeType?: string, bytesBase64?: string, error?: string }
host_list_collection(ptr: i32, len: i32) -> i64
input: { repo: string, collection: string, cursor?: string, limit?: number }
output: { ok: bool, records?: [{uri, cid, value}], cursor?: string, error?: string }
All host functions are read-only. limit is clamped to 100.
at-functions/
├── lexicons/ AT Lexicon definitions (published, see "Lexicons" below)
│ ├── at.functions.metadata.json
│ ├── at.functions.run.json
│ └── at.functions.workflow.json
├── src/
│ ├── server.ts Fastify entry point
│ ├── routes/run.ts POST /xrpc/at.functions.run
│ ├── lib/
│ │ ├── atproto.ts AT Proto SDK helpers
│ │ ├── schemas.ts JSON Schema for Fastify validation
│ │ └── types.ts TypeScript types
│ └── wasm/
│ ├── abi.ts ptr/len packing, memory I/O helpers
│ ├── executePure.ts pure-v1 executor
│ ├── executeHost.ts host-v1 executor (worker thread coordinator)
│ ├── hostWorker.mjs worker thread: runs WASM, uses Atomics.wait
│ ├── hostImports.ts (reference) host import implementations
│ ├── executeComponent.ts component-v1 executor
│ ├── componentHost.ts typed WIT host implementation
│ ├── componentRunner.mjs component worker (jco transpilation)
│ ├── executeWorkflow.ts workflow executor
│ └── moduleCache.ts CID → compiled WebAssembly.Module cache
├── examples/
│ ├── pure-rust/ Echo function (pure-v1)
│ ├── host-rust/ Collection lister (host-v1)
│ └── component-rust/ WIT component (component-v1)
└── scripts/
├── upload-function.ts
├── create-function-record.ts
├── deploy-function.ts
├── publish-lexicons.ts
├── invoke.ts
├── demo.sh
└── demo-component.sh
- Node.js 22+
- pnpm 10+ (or
corepack enable) - Rust + wasm32-unknown-unknown target (for Rust examples)
- A Bluesky / AT Proto account + app password (for uploading functions)
pnpm installcp .env.example .env
# Fill in ATPROTO_IDENTIFIER and ATPROTO_PASSWORDpnpm run dev # development (watch)
pnpm run build && pnpm start # productionThe server starts on http://localhost:3000.
Both examples target wasm32-unknown-unknown (no WASI, no std I/O).
# Install the target once
rustup target add wasm32-unknown-unknown
# Compile pure echo example
cd examples/pure-rust
cargo build --target wasm32-unknown-unknown --release
# Output: target/wasm32-unknown-unknown/release/pure_echo.wasm
# Compile host lister example
cd examples/host-rust
cargo build --target wasm32-unknown-unknown --release
# Output: target/wasm32-unknown-unknown/release/host_lister.wasmATPROTO_IDENTIFIER=you.bsky.social ATPROTO_PASSWORD=your-app-password \
pnpm exec tsx scripts/upload-function.ts \
examples/pure-rust/target/wasm32-unknown-unknown/release/pure_echo.wasmNote the blob JSON printed at the end; you'll use it in the next step.
ATPROTO_IDENTIFIER=you.bsky.social ATPROTO_PASSWORD=your-app-password \
pnpm exec tsx scripts/create-function-record.ts \
--name "echo" \
--version "0.1.0" \
--mode "pure-v1" \
--rkey "echo-v1" \
--description "Echoes the input JSON back to you." \
--inputSchema '{"type":"object","properties":{"hello":{"type":"string"},"num":{"type":"number"}},"required":["hello"],"additionalProperties":true}' \
--outputSchema '{"type":"object","properties":{"ok":{"type":"boolean"},"echo":{"type":"object"}},"required":["ok","echo"]}' \
--blob '<paste the full JSON blob object printed by scripts/upload-function.ts>'This creates an at.functions.metadata record on your AT Proto repo.
The script prints the at:// URI for the record.
# Start the server first (in another terminal):
pnpm run dev
# Then invoke:
pnpm exec tsx scripts/invoke.ts \
--function "at://did:plc:yourDID/at.functions.metadata/echo-v1" \
--input '{"hello":"world","num":42}'Expected response:
{
"ok": true,
"output": { "ok": true, "echo": { "hello": "world", "num": 42 }, "mode": "pure-v1" },
"durationMs": 12,
"functionCid": "bafy..."
}Upload and register host_lister.wasm with --mode "host-v1", then invoke:
pnpm exec tsx scripts/invoke.ts \
--function "at://did:plc:yourDID/at.functions.metadata/lister-v1" \
--input '{"repo":"did:plc:yourDID","collection":"app.bsky.feed.post","limit":5}'Expected response:
{
"ok": true,
"output": { "ok": true, "count": 5, "uris": ["at://..."], "collection": "app.bsky.feed.post" },
"durationMs": 340
}Request body:
{
"function": "at://did:plc:.../at.functions.metadata/my-fn",
"input": { "any": "json" }
}Response:
{
"ok": true,
"output": { "...": "..." },
"durationMs": 45,
"functionCid": "bafy..."
}On failure: ok: false, error: "reason".
Server-side function discovery backed by AT Search: finds published functions across the whole network (any PDS), no repo enumeration. The browser UI queries AT Search directly (PUBLIC_ATSEARCH_URL); this endpoint is the same capability for API consumers and agents.
GET /xrpc/at.functions.discover?q=resize+image&limit=10
q(optional): free text; omit to list every indexed functionlimit(optional, default 25, max 100)
Response:
{
"ok": true,
"query": "resize image",
"count": 1,
"functions": [
{
"uri": "at://did:plc:.../at.functions.metadata/resize-v1",
"cid": "bafy...",
"name": "resize v1.0.0",
"description": "...",
"tags": ["at-functions", "wasm", "pure-v1"],
"score": 9,
"verified": true
}
]
}Configure the AT Search query node with ATSEARCH_QUERY_URL (default http://127.0.0.1:13002; on the shared docker-compose stack, http://query-node:3002). Returns 502 when AT Search is unreachable.
pnpm testTests cover:
- AT URI parsing
packPtrLen/unpackPtrLencallWasmFunctionwith fake exports- Host import ABI contract
This POC is NOT production-safe:
- WASM runs in-process with no OS-level isolation
maxMemoryMbis enforced via initial page count only; the module can request more viamemory.growmaxDurationMsusesPromise.racewithsetTimeout: the WASM may continue running briefly after timeout- No authentication or authorisation on the
/xrpc/at.functions.runendpoint host-v1functions can read any public AT Proto data
For production: use a separate process, seccomp/landlock/VM isolation, and proper memory limits enforced at the V8/Wasm engine level.
POST /xrpc/at.functions.run
│
├─ parseAtUri(at://...)
├─ fetchFunctionRecord → AT Proto getRecord
├─ fetchBlob → AT Proto sync.getBlob
│
├─ pure-v1 ──────────────────────────────────────────────┐
│ WebAssembly.compile(wasmBytes) │
│ WebAssembly.instantiate(module, {}) │
│ alloc(inputLen) → ptr │
│ write input bytes → memory[ptr] │
│ run(ptr, len) → packed i64 │
│ unpack → outPtr, outLen │
│ decode memory[outPtr..outPtr+outLen] → JSON │
│ │
└─ host-v1 ──────────────────────────────────────────────┘
Main thread spawns Worker (hostWorker.mjs)
Worker instantiates WASM with host imports
Host import → Atomics.wait (blocks worker thread)
Main thread event loop picks up host request
Main thread → AT Proto async I/O
Main thread writes response → SharedArrayBuffer
Atomics.notify → worker unblocks
Worker reads response, continues execution
Worker sends final output → parentPort.postMessage
Main thread resolves Promise → JSON response
The UI is a static bundle with PUBLIC_* values baked in at build time, so it
must be rebuilt whenever those change.
docker buildx build --target ui-artifact \
--build-arg PUBLIC_ATSEARCH_URL=https://api.functions.at \
--build-arg PUBLIC_AT_FUNCTIONS_API=https://api.functions.at \
--output type=local,dest=./ui-out .
rsync -rlt --delete --chmod=D755,F644 ui-out/ ui/build/Use --chmod, not rsync -a. BuildKit's local export carries the
permissions of a FROM scratch layer, and -a preserves them, which leaves
the files unreadable by nginx's worker and serves a 403 on every page load.
--chmod=D755,F644 forces sane modes, so the deploy is self-correcting rather
than needing a chmod -R a+rX afterwards.
Schema definitions live in lexicons/: at.functions.metadata (function records), at.functions.run (the XRPC run method), and at.functions.workflow.
The lexicons are published as com.atproto.lexicon.schema records in the authority repo, so any AT Proto tool can resolve them from the bare NSID with no knowledge of this repo:
- DNS:
_lexicon.functions.atTXT →did=did:plc:vsnj4aaxyatiht4spdht2q2t - Records:
at://did:plc:vsnj4aaxyatiht4spdht2q2t/com.atproto.lexicon.schema/at.functions.*
Re-publish after changing a lexicon file (the record key is the NSID, so this is an idempotent upsert):
pnpm exec tsx scripts/publish-lexicons.ts(Reads ATPROTO_IDENTIFIER / ATPROTO_PASSWORD; restrict with LEXICON_IDS=at.functions.metadata.)
Verify resolution end-to-end with the official resolver:
import { resolveLexicon } from '@atproto/lexicon-resolver'
await resolveLexicon('at.functions.metadata') // → { uri, cid, lexicon }Because the schema is published, schema-aware indexers can index at.functions.metadata records automatically. AT Search indexes them off the firehose; searching for functions is one HTTP call against its query node, with no PDS enumeration needed:
GET /search?q=type:at.functions.metadata # list all indexed functions
GET /search?q=resize+image+type:at.functions.metadata # free-text within functions
This project demonstrates the concept of AT Protocol as a code registry. It is not:
- A production runtime
- A secure sandbox
- A multi-tenant system
- WASI-compatible
It is a proof of concept to explore the idea and invite further discussion.
MIT. Copyright (c) 2026 Andrew Vivash.