at-run #
Serverless JavaScript execution on AT Protocol. Deploy bundles to your PDS, execute them anywhere.
┌─────────────┐ deploy ┌─────────────┐
│ CLI │ ───────────► │ PDS │
│ at-run │ │ (your data) │
└─────────────┘ └──────┬──────┘
│ fetch
▼
┌─────────────┐ request ┌─────────────┐ sandbox ┌─────────────┐
│ Client │ ───────────► │ Runner │ ───────────► │ Deno │
└─────────────┘ │ (Bun HTTP) │ │ (sandboxed) │
└─────────────┘ └─────────────┘
Packages #
- @at-run/runtime - Core types and helpers for bundle authors
- @at-run/cli - Deploy bundles to your PDS
- @at-run/runner - HTTP server that fetches and executes bundles
Quick Start #
1. Create a Bundle #
// my-api/index.ts
import { manifest, endpoint, v } from "@at-run/runtime"
// IMPORTANT: Re-export v for sandbox input validation
export { v }
// Declare bundle metadata and permissions
export const bundle = manifest({
name: "my-api",
description: "My serverless API",
permissions: {
net: ["api.example.com"], // allowed network hosts
},
})
// Define endpoints with input validation
export const hello = endpoint({
input: v.object({
name: v.optional(v.string()),
}),
handler: async (args) => {
return { message: `Hello, ${args.name || "world"}!` }
},
})
export const fetchData = endpoint({
handler: async () => {
const res = await fetch("https://api.example.com/data")
return res.json()
},
})
2. Build the Bundle #
bun build my-api/index.ts --outfile=dist/bundle.js --target=browser
Important: Use
--target=browserfor Deno compatibility.
3. Deploy to Your PDS #
# Login (one-time)
at-run login
# Deploy
at-run deploy dist/bundle.js
Output:
Bundle: dist/bundle.js (12.3 KB)
Name: my-api
Version: 0.1.0
✓ Deployed!
AT URI: at://did:plc:xxx/dev.mainasara.at-run.beta.bundle/my-api-0-1-0
4. Execute via Runner #
# Start the runner
at-run-runner
# Call your endpoint
curl -X POST http://localhost:3000/bundle/did:plc:xxx/my-api/latest/hello \
-H "Content-Type: application/json" \
-d '{"name": "Alice"}'
# Response: {"message": "Hello, Alice!"}
CLI Reference #
Commands #
| Command | Description |
|---|---|
at-run login |
Authenticate with your PDS |
at-run logout |
Clear saved session |
at-run whoami |
Show current user |
at-run deploy <bundle.js> |
Deploy a bundle |
at-run list |
List deployed bundles |
at-run delete <name|rkey> |
Delete bundle(s) |
Version Flags #
at-run deploy bundle.js # Bumps patch: 0.1.0 -> 0.1.1
at-run deploy bundle.js --minor # Bumps minor: 0.1.1 -> 0.2.0
at-run deploy bundle.js --major # Bumps major: 0.2.0 -> 1.0.0
at-run deploy bundle.js --version=2.0.0 # Explicit version
Runner Reference #
URL Formats #
| Format | Example |
|---|---|
| Raw AT URI | /at://did:plc:xxx/dev.mainasara.at-run.beta.bundle/my-api-0-1-0/hello |
| Named + version | /bundle/did:plc:xxx/my-api/0.1.0/hello |
| Named + latest | /bundle/did:plc:xxx/my-api/latest/hello |
| Local (dev only) | /local/path/to/bundle.js/hello |
Environment Variables #
| Variable | Description |
|---|---|
PORT |
Server port (default: 3000) |
DEV |
Enable local bundle execution and verbose logging |
SANDBOX_LOCAL |
Sandbox local bundles too |
Job Scheduler Endpoints #
| Endpoint | Description |
|---|---|
GET /jobs/status |
Scheduler status (running, enabled/pending job counts) |
POST /jobs/notify |
Receive job notifications from CLI |
Request Format #
GET with query params:
GET /bundle/did/name/latest/endpoint?arg1=value1&arg2=value2
GET with JSON args:
GET /bundle/did/name/latest/endpoint?args={"arg1":"value1"}
POST with JSON body:
POST /bundle/did/name/latest/endpoint
Content-Type: application/json
{"arg1": "value1"}
Permissions #
Bundles declare required permissions in their manifest. The runner enforces these via Deno's permission system.
export const bundle = manifest({
name: "my-api",
permissions: {
net: ["api.example.com", "cdn.example.com"], // Network access
read: ["/data"], // Filesystem read
write: ["/tmp"], // Filesystem write
run: ["ffmpeg"], // Subprocess execution
ffi: ["/usr/lib/libfoo.so"], // Dynamic libraries
sys: ["cpus", "systemMemoryInfo"], // System info APIs
hrtime: true, // High-resolution time
},
})
Environment Variables & Secrets #
Environment variables are not declared in the manifest. Instead, they are derived from configured secrets:
- Bundle author sets secrets for a specific runner using
at-run secrets set - Secrets are encrypted with the runner's public key
- At runtime, the runner decrypts secrets and injects them as env vars
--allow-envis automatically set to only the secret keys
# Set a secret for your bundle on a runner
at-run secrets set my-api runner-did API_KEY sk-xxx
# In your bundle, access it via Deno.env
const apiKey = Deno.env.get("API_KEY")
This ensures bundles can only access env vars they have secrets for, preventing leakage of system environment.
Background Jobs #
Jobs are scheduled tasks that run on a runner. Unlike endpoints (request/response), jobs execute on a schedule - either once at a specific time or recurring via cron.
Architecture #
┌─────────────┐ create job ┌─────────────┐
│ CLI │ ──────────────►│ PDS │ Jobs stored on author's PDS
└──────┬──────┘ └─────────────┘
│ notify
▼
┌─────────────┐ tick loop ┌─────────────┐
│ Runner │ ──────────────►│ SQLite │ Execution state stored locally
│ (scheduler) │ │ (jobs.db) │
└─────────────┘ └─────────────┘
Key principles:
- Authors own their jobs (stored on their PDS)
- Runners are stateless executors (only track execution state locally)
- Push model: CLI notifies runner when jobs change (no polling)
1. Define Jobs in Your Bundle #
// my-bundle/index.ts
import { manifest, job } from "@at-run/runtime"
export const bundle = manifest({
name: "my-tasks",
permissions: {
write: ["/tmp/data"],
read: ["/tmp/data"],
},
})
// One-time job
export const initData = job({
description: "Initialize data file",
schedule: { type: "once" }, // Default schedule (can override at creation)
permissions: {
write: ["/tmp/data"],
},
handler: async () => {
await Deno.writeTextFile("/tmp/data/init.txt", `Created at ${new Date().toISOString()}`)
return { success: true }
},
})
// Recurring job
export const updateStats = job({
description: "Update stats every hour",
schedule: {
type: "cron",
cron: "0 * * * *", // Every hour
},
handler: async () => {
const stats = await collectStats()
await Deno.writeTextFile("/tmp/data/stats.json", JSON.stringify(stats))
return { updated: true }
},
})
2. Deploy the Bundle #
bun build my-bundle/index.ts --outfile=dist/my-tasks.js --target=browser
at-run deploy dist/my-tasks.js
3. Create Jobs #
# One-time job at a specific time
at-run jobs create my-tasks initData --runner did:plc:xxx \
--at "2026-04-05T10:00:00Z"
# Recurring cron job (every minute)
at-run jobs create my-tasks updateStats --runner did:plc:xxx \
--cron "* * * * *"
# With timezone
at-run jobs create my-tasks updateStats --runner did:plc:xxx \
--cron "0 9 * * *" --timezone "America/New_York"
4. Configure Runner #
Add to ~/.at-run/runner.json:
{
"did": "did:plc:your-runner-did",
"jobs": {
"enabled": true,
"tickIntervalMs": 1000,
"dbPath": "~/.at-run/jobs.db"
}
}
The runner DID is required - the scheduler verifies jobs target this runner.
Job CLI Commands #
| Command | Description |
|---|---|
at-run jobs list [bundle] |
List all jobs (optionally filter by bundle) |
at-run jobs create <bundle> <job> --runner <did> |
Create a job |
at-run jobs run <bundle> <job> |
Manually trigger a job and wait for result |
at-run jobs enable <bundle> <job> |
Enable a job |
at-run jobs disable <bundle> <job> |
Disable a job |
at-run jobs delete <bundle> <job> |
Delete a job |
Manual Job Execution #
Trigger a job immediately and see the result:
at-run jobs run my-tasks updateStats --job-version=0.1.5
The command waits for the job to complete and prints the return value, useful for debugging.
Job Version Flags #
Jobs have their own versioning, separate from bundle versions:
# Auto-bump (default: patch)
at-run jobs create my-tasks updateStats --runner did:plc:xxx --cron "* * * * *"
# Creates: my-tasks/updateStats@0.1.0
# Bump minor
at-run jobs create my-tasks updateStats --runner did:plc:xxx --minor --cron "0 * * * *"
# Creates: my-tasks/updateStats@0.2.0
# Explicit versions
at-run jobs create my-tasks updateStats --runner did:plc:xxx \
--job-version=1.0.0 \
--bundle-version=2.0.0 \
--cron "0 * * * *"
Job Options #
| Option | Description |
|---|---|
--runner=<did> |
Target runner DID (required) |
--cron="expr" |
Cron expression (5-field) |
--at=<ISO> |
One-time execution datetime |
--timezone=<tz> |
IANA timezone (default: UTC) |
--until=<ISO> |
Stop scheduling after this time |
--input='{"key":"val"}' |
JSON input arguments |
--bundle-version=x.y.z |
Target specific bundle version |
--job-version=x.y.z |
Set explicit job version |
--patch/--minor/--major |
Auto-bump job version |
Under Review #
Note: The following features are under review and may change.
Job Version vs Bundle Version #
Jobs have their own versioning independent from bundle versions. When working with jobs:
--job-versionrefers to the job record's version (e.g.,0.1.10)--bundle-versionrefers to which bundle version the job executes (e.g.,0.2.7)
Use at-run jobs list to see both versions for each job.
Job Update Workflow #
When updating job code, the typical workflow is:
# 1. Deploy new bundle version
at-run deploy dist/bundle.js
# 2. Disable old job version
at-run jobs disable my-bundle myJob --job-version=0.1.5
# 3. Create new job pointing to new bundle
at-run jobs create my-bundle myJob --runner did:plc:xxx \
--bundle-version=0.2.0 --cron "*/10 * * * *"
Cron Expressions #
Standard 5-field cron format:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, 0=Sunday)
│ │ │ │ │
* * * * *
Examples:
* * * * *- Every minute0 * * * *- Every hour0 0 * * *- Daily at midnight0 9 * * 1- Every Monday at 9am*/15 * * * *- Every 15 minutes
Per-Endpoint Permissions #
Endpoints can declare additional restrictions:
export const restrictedEndpoint = endpoint({
permissions: {
net: ["internal.example.com"], // Only this host, even if manifest allows more
},
handler: async () => {
// ...
},
})
The effective permissions are the intersection of manifest and endpoint permissions.
Returning Response Objects #
Endpoints can return raw Response objects for binary data, custom headers, or caching:
export const getImage = endpoint({
input: v.object({ id: v.string() }),
async handler({ id }) {
const bytes = await Deno.readFile(`/tmp/images/${id}.jpg`)
return new Response(bytes, {
headers: {
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=86400",
},
})
},
})
export const getDataWithCache = endpoint({
input: v.object({ key: v.string() }),
async handler({ key }) {
const data = await fetchData(key)
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
},
})
},
})
The runner automatically handles binary data by encoding it through the sandbox boundary.
CORS Headers #
The runner automatically adds CORS headers to all responses:
Access-Control-Allow-Origin: *Access-Control-Allow-Methods: GET, POST, OPTIONSAccess-Control-Allow-Headers: Content-Type
If your endpoint's Response already includes these headers, they are preserved.
Performance Best Practices #
Batch Endpoints #
When clients need to fetch data for many items, create batch endpoints to reduce request count:
// Instead of: GET /profile?did=xxx (called 50 times)
// Use: POST /batchProfiles with { dids: [...] } (called once)
const BatchProfilesSchema = v.object({
dids: v.array(v.string()),
})
export const batchProfiles = endpoint({
input: BatchProfilesSchema,
async handler({ dids }) {
const results: Record<string, Profile | null> = {}
await Promise.all(
dids.map(async (did) => {
try {
results[did] = await fetchProfile(did)
} catch {
results[did] = null
}
})
)
return results
},
})
This reduces hundreds of concurrent requests to a single batch call, improving both client and server performance.
Cacheable GET Endpoints #
For data that can be cached by browsers, use GET requests with query parameters:
// Client can use: GET /thumbnail?uri=at://...
// Browser caches based on full URL
export const getThumbnail = endpoint({
input: v.object({ uri: v.string() }),
async handler({ uri }) {
const bytes = await loadThumbnail(uri)
return new Response(bytes, {
headers: {
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=86400, immutable",
},
})
},
})
Lexicons #
Bundle Record #
dev.mainasara.at-run.beta.bundle:
{
"$type": "dev.mainasara.at-run.beta.bundle",
"name": "my-api",
"description": "My serverless API",
"version": "0.1.0",
"blob": { "$type": "blob", "ref": { "$link": "bafkrei..." } },
"permissions": { "net": ["api.example.com"] },
"createdAt": "2026-04-01T00:00:00.000Z"
}
Job Record #
dev.mainasara.at-run.beta.job:
{
"$type": "dev.mainasara.at-run.beta.job",
"bundleName": "my-tasks",
"jobName": "updateStats",
"jobVersion": "0.1.0",
"bundleVersion": "1.0.0",
"bundle": "at://did:plc:xxx/dev.mainasara.at-run.beta.bundle/my-tasks-1-0-0",
"runner": "did:plc:runner-did",
"schedule": {
"type": "cron",
"cron": "0 * * * *",
"timezone": "UTC"
},
"enabled": true,
"description": "Update stats every hour",
"createdAt": "2026-04-01T00:00:00.000Z"
}
Record key format: {bundleName}--{jobName}-{major}-{minor}-{patch}
Example: my-tasks--updateStats-0-1-0
## Development
### Multiple Entry Points
A project can have separate bundles for endpoints and jobs:
my-app/ src/ index.ts # Endpoints bundle (e.g., "my-api") jobs.ts # Jobs bundle (e.g., "my-api-jobs")
Build and deploy them separately:
```bash
# Build both bundles
bun build src/index.ts --outfile=dist/api.js --target=browser
bun build src/jobs.ts --outfile=dist/jobs.js --target=browser
# Deploy both
at-run deploy dist/api.js
at-run deploy dist/jobs.js
Local Testing #
# Build your bundle
bun build src/index.ts --outfile=dist/bundle.js --target=browser
# Start runner in dev mode
DEV=true bun packages/at-run/runner/src/index.ts
# Test locally (unsandboxed)
curl http://localhost:3000/local/$(pwd)/dist/bundle.js/hello
# Test locally with sandbox
SANDBOX_LOCAL=true DEV=true bun packages/at-run/runner/src/index.ts
Requirements #
- Bun - Runtime and bundler
- Deno - Sandbox execution (must be in PATH)
Why at-run? #
- Portable: Your code lives on your PDS, not a vendor's servers
- Sandboxed: Deno enforces declared permissions at runtime
- Versioned: Semantic versioning with automatic bumping
- Decentralized: Anyone can run a runner, bundles are fetched from any PDS
Roadmap #
See ROADMAP.md for future ideas beyond the PoC.
License #
MIT