An entry for the streamplace vod showcase
README.md

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=browser for 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:

  1. Bundle author sets secrets for a specific runner using at-run secrets set
  2. Secrets are encrypted with the runner's public key
  3. At runtime, the runner decrypts secrets and injects them as env vars
  4. --allow-env is 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-version refers to the job record's version (e.g., 0.1.10)
  • --bundle-version refers 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 minute
  • 0 * * * * - Every hour
  • 0 0 * * * - Daily at midnight
  • 0 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, OPTIONS
  • Access-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