ACTIAS

Durable objects, workflows and realtime, in one file.

Publish a script and it answers at a URL. Whatever it asks for exists the first time it runs. Open source, and yours to run.

You could build
an auction that cannot double-bid
one object per lot, one call at a time
Read the docs
local visits = kv "visits"

on "fetch" (function(request)
    local count = (visits:get("count") or 0) + 1
    visits:set("count", count)
    return {
        body = json.stringify({ visits = count }),
    }
end)
createskv "visits"
actias init

A folder, a main.lua, and the types your editor needs.

your first script
actias check

Reads the same declarations the platform does, so the two cannot drift.

get the cli
actias publish

The URL answers and the storage it asked for is in the console.

how a request runs
docker compose up

Or run the whole platform locally, workers and all.

the cluster

One line each

Nothing here exists until your code asks for it. Then it does, and the console can show you inside it.

Two bids, the same millisecond

Usually this is where you reach for a transaction, or a lock, or a Redis mutex you are fairly sure you got right.

An object here takes one call at a time. The second bid waits, reads the number the first one wrote, and loses. Nothing in the example above locks anything, because there is nothing to lock.

bid(mira, 4200)bid(sol, 4200)bid(anon, 3900)QUEUEONE AT A TIMEAuction("lot-42")its own SQLite filealarms it sets itselfpublishes: bids
databases/Auction/lot-42
IDUSERAMOUNT
37mira4,200
36sol4,050
35anon3,900
34mira3,400
streaming bids, 2 followers

The console is reading that object's own SQLite file. Same for queue journals and workflow runs. The shell asks the same things in a line of Lua, from the console or the cli.

One writer is a placement decision, not a lock

leases and placement
Most objects are just a file

Nothing is running. Auction("lot-42") is a name and a SQLite file sitting in storage, so having a million of them costs storage and no memory.

callersAuction("lot-42")worker aworker bLEASE HELDworker cLEASE HELDgonereplicareplicaalways the same workerloads its fileone writeanswered on a quorumthen the storethe next call lands on claid from its own copyflushes, lease lapsesobject storage, one SQLite file per instancelot-42

Idle instances cost storage, not memory, so a project can hold a million names and only pay for the ones in use.

Take a peek: a chat platform, all Actias

websockets

Rooms, threads, presence, mentions, moderation, a nightly digest. Hover a box to read what it is, click to pin it, and switch the view to watch a message travel or see what happens when everybody logs off.

the pieces, and the calls between them
object storage, one SQLite file per instancemodel APIAPNs / FCMopen tabsassets/served from the bundleconnection "Session"one per open socketmain.luafetch + upgradeon "schedule"nightly digestChannel:list { … }every room, none wokenServer("acme")one per communityPresence("mira")one per memberChannel("general")busy roomChannel("dev")quiet roomThread("#4821")lives under a roomworkflow "Report"runs for dayssql "search"project databasekv "settings"small valuesqueue "push"off the hot pathsecret "APNS_KEY"versionedconnection "Model"outbound, dialled by the room
NO TARGET
Nothing selected

One project, one repository. Every box is a line somebody wrote, and the lines between them are calls and streams. Hover either to read what it does.

view: structureHover a box or a line. Click either to lock it open.
Every call in the drawing, in words
the open socketconn:send(value)
The wire itself, held by the connection rather than by a handler. Frames the client sends arrive at its frame handler, and whatever it follows arrives at its event handler.
an ordinary requeston "fetch" (handler)
Not every tab holds a socket. A plain request hits the same handler, gets a clean run, and is gone.
the upgraderequest:upgrade(Session, seed, User(name))
A handshake is an ordinary request, so it authenticates in the same router. The upgrade names the connection class, seeds conn.state, and fixes the identity the socket speaks as.
connection edgeconn:follow(Channel(room), "message")
Both directions of the socket path. The frame handler posts into the room, and the follow brings the room’s published events back. At-most-once, no retry, and it dies with the socket, so anything that must not be missed is followed one hop back by an object.
serving the clientassets served from the bundle
The same handler serves the front end. Static files ride in the bundle with the script, so the client and the code it talks to ship together.
gateway to serverServer(id):channels()
The handler asks the community object for its channel list, roles and invites.
gateway to presencePresence(user):touch()
Connecting marks the member online inside their own object rather than a shared table.
an http postChannel(name):post(msg)
The path for clients without a socket open. Same call into the room, made from an ordinary request instead of a frame.
gateway to a sleeping roomChannel(name):post(msg)
The same call to a room nobody is using. It wakes where its file is and answers.
gateway to threadThread(id):post(msg)
Threads take posts directly, so a busy thread never queues behind its room.
listing the roomsChannel:list { where = { … } }
The handler lists rooms from the index. Nothing wakes; the rows are what each room last wrote about itself.
a row per writedirectory = { from, fields }
After any call that wrote, the room derives its row and the index takes it. Methods never mention the index, so the row cannot drift from the state it reads.
nightly digeston "schedule" (handler)
The schedule walks each community once a night and hands the mail to the queue.
presence streamstate:publish("presence", …)
Presence publishes, the server follows. The member owns the write, so nobody contends for a shared row.
server to roomChannel(name):configure(…)
Renames, topics and permissions travel from the community object into the room.
thread to roomChannel(name):bump(thread)
A thread tells its room it has activity, so the room list can order itself without scanning anything.
starting a workflowworkflow "Report" (fn)
A report starts a run that parks until a moderator decides, then continues on its own.
queue reads prefskv "settings":get(user)
Before sending anything the consumer checks whether that member wants the push at all.
indexingsql "search":exec(…)
The room writes a copy into the shared index, which is how search spans rooms without touching their private files.
mention to queuequeue "push":send(mention)
Mentions leave the request path here.
consumer reads the secretsecret "APNS_KEY"
The credential is read at send time. It is versioned, rotatable, and never inside the bundle.
the room asks the modelstate:publish("upstream", prompt)
The room publishes the prompt on a topic its follow hook admits only the model wire to; the wire sends it raw. Tokens come back as calls into the room, one at a time, each durable before the next is handled.
leaving Actias, from insidewss to the provider
The other hop that is not yours, this one dialled by the project. When the provider drops it, the close hook says why and the room reopens it; when the node holding it dies, the room’s alarm does.
leaving Actiashttp to APNs
The one hop that is not yours. What fails comes back as a retry and eventually a dead letter you can read.
object storagestate.sql
Every instance owns a SQLite file. Loading and flushing happen at the edges of its life, not on every call.

Hosted, and Wasm

Today it runs on your machine and the APIs still move without much warning. Two things change who it is for.

Hostedthe same platform, run by us
Wasma second runtime beside Luau
Stable APIsversioned, and documented
Keep up with the project

Leave an address and it goes on the list for the first announcement: what shipped, what broke, and when hosted opens.

Five minutes on your own machine

Compose up, init, publish, hit the URL. If something falls over, open an issue.