Luau on LibreMetaverse

The client surface hooked into the grid, and how we drive bots & the viewer with Luau.

How it works

A bot is a LibreMetaverse grid client with an embedded Luau VM behind a C ABI shim. One worker thread owns the VM; grid events marshal onto it. Scripts touch the grid only through one global table, client.

The viewer (Alchemy) embeds the same VM and implements the same client contract over its C++ managers. A Luau script that targets this document runs verbatim on either host — the binding code differs, the surface does not.

Bots and the viewer are both WebSocket clients of one hub. Each registers under an id (a bot registers under its SL username; the viewer registers too), and the hub relays control requests to any of them — so many bots and viewers are drivable at once, including over MCP tooling.

Conventions

KindMeaning
commandSends / acts; returns immediately (some return an id or handle).
snapshotReads host-held state, copied into a fresh Luau table — never a live handle.
queryReturns a simple host value inline (a bool, a number).
eventclient.on_*(fn) registers fn; the host calls it once per occurrence on the worker thread. Re-registering replaces the previous handler for that slot.
deferred-asyncThe result does not return inline — it arrives later through a named on_* event. The worker is never blocked on the round-trip.
completionTwo spellings for one correlated request (see below).

Completion — callback or await. Pass a trailing fn(ok, err) and it fires once when the reply (or failure) correlates back; or omit it and call from inside a coroutine: local ok, err = client.self.sit(uuid) yields the coroutine and returns (ok, err) when the reply arrives. bot.eval runs your source on the coroutine path, so a top-level await suspends the eval and resumes on the reply — the worker stays free during the wait. ok=false carries an error string ("timeout", or a server alert). Never blocking: the reply is delivered onto the same worker thread, so a busy-wait would deadlock.

UUIDs cross the boundary as lowercase strings. Vectors are {x,y,z}; quaternions {x,y,z,w}. Two universal channels backstop everything: client.on_message(type, fn) / client.send_message(type, table) (raw classic-UDP), and client.on_alert(fn) (the server's generic notice text).

Running Luau in a bot

The hub exposes MCP tools (and the same calls over its WebSocket) to reach any registered bot or viewer:

ToolArgsDoes
bots_listIds currently registered with the hub.
bot_eval{id, source}Run Luau source inside a bot; returns its result plus anything it printed. Runs on the coroutine path (top-level await works).
bot_services{id}List the bot's services with runtime state and whether each is enabled at startup.
bot_service{id, action, name}start/stop/restart act now; enable/disable set whether the service runs at startup (persisted by the hub) and apply live if connected.
-- via bot_eval
return client.self.info().name        -- "inworldhelp Resident"
client.chat("hello from luau", 0)
for _, f in client.friends.list() do print(f.name) end

Services

A service is a .luau module in the bot's include directory that returns {name?, start, stop}. The hub holds, per bot, which services run at startup (persisted, survives restarts) and starts them once the bot signals it is ready (post-login). start() should be light — set up timers / event handlers, not act — and stop() must clear every interval and reset handlers.

local M = { name = "heartbeat" }
local timer
function M.start()
  timer = client.set_interval(30, function()
    print("[heartbeat] as " .. client.self.info().name)
  end)
end
function M.stop()
  if timer then client.clear_interval(timer); timer = nil end
end
return M

Enable it for a bot with bot_service {id, action="enable", name="heartbeat"}. It then starts on every (re)connect until disabled.

client — network, chat, IM

PathKindReturns / payload
client.login{first,last,password,channel,uri,start}commandtrue; non-blocking, outcome via on_login_progress.
client.logout()command
client.connected()querybool
client.chat(message, channel?, type?)commandchannel=0, type="normal"
client.instant_message(uuid, message)command
client.on_login_progress(fn)eventfn(status, code, message, fail_reason)
client.on_disconnect(fn)eventfn(reason, message)
client.on_chat(fn)eventfn{message, from_name, source_id, owner_id, type, type_name, source_type, audible, position}
client.on_instant_message(fn)eventfn{message, from_name, from_agent_id, to_agent_id, session_id, dialog, dialog_name, group}

client.self — movement & actions

PathKindReturns / payload
client.self.info()snapshot{agent_id, session_id, name, first_name, last_name, active_group, local_id, position}
client.self.teleport(region, x, y, z)deferred-asyncresult via on_teleport
client.self.touch(local_id)command
client.self.sit(uuid, offset?, fn(ok,err)?)completioncorrelated to AvatarSitResponse, 10s timeout
client.self.stand()command
client.self.walk_to(x, y, z?)commandregion-local autopilot
client.self.animate(uuid) / .animation_stop(uuid)command
client.self.fly(on?) / .jump(on?)commandon=true
client.self.set_home() / .balance()command / snapshotbalance → L$ integer
client.self.request_balance()deferred-asyncresult via on_money_balance
client.self.pay(uuid, amount, desc?) / .pay_object(uuid, amount, name?)command
client.self.offer_teleport(uuid, msg?) / .respond_teleport(requester, session, accept)commandincoming offer via on_instant_message
client.self.reply_dialog(channel, button_index, button_label, object_id)commandanswer a blue menu
client.self.answer_permissions(item_id, task_id, mask)commandgrant script permission bits
client.self.attach(item_uuid, point?) / .detach(item_uuid)commandwear / remove an inventory item
client.on_teleport(fn)eventfn{message, status, status_code}
client.on_alert(fn)eventfn{message, notification_id}
client.on_script_dialog(fn)eventfn{message, object_name, object_id, owner_id, channel, buttons[]}
client.on_script_question(fn)eventfn{task_id, item_id, object_name, owner_name, permissions}
client.on_money_balance(fn)eventfn{balance, success, description, transaction_id}
client.on_sit_response(fn)eventfn{object_id, autopilot, force_mouselook, position, rotation}
client.on_nearby(fn)eventfn{avatars[]{id, position}, entered[], left[]} — nearby-avatar radar

client — timers

PathKindReturns / payload
client.set_interval(seconds, fn)commandhandle; calls fn on the worker every seconds. Composes — multiple intervals coexist.
client.clear_interval(handle)commandstops that interval.

client — raw escape hatch

The universal backstop: subscribe to and send any classic-UDP PacketType by name. A reflection marshaller decodes a packet into {_type, <BlockName> = {fields} | {block[]}} and builds one from the same shape. Anything without a typed binding is reachable here.

PathKindReturns / payload
client.on_message(type_name, fn)eventfn(packet_table) — per-type opt-in
client.send_message(type_name, table)commandbuilds + sends

client.friends

PathKindReturns / payload
client.friends.request(uuid)command
client.friends.accept(from_uuid, session_uuid) / .decline(...)command
client.friends.remove(uuid)command
client.friends.list()snapshotarray of friend
client.on_friendship_offered(fn)eventfn{from_agent_id, from_name, session_id, message}
client.on_friendship_response(fn)eventfn{agent_id, name, accepted}
client.on_friend_online(fn) / .on_friend_offline(fn)eventfn(friend)

friend = {uuid, name, online, rights{can_see_me_online, can_see_me_on_map, can_modify_my_objects, can_see_them_online, can_see_them_on_map, can_modify_their_objects}}

client.avatars

PathKindReturns / payload
client.avatars.request_name(uuid) / .request_names(uuid[])deferred-asyncresult via on_avatar_name
client.avatars.request_properties(uuid)deferred-asyncresult via on_avatar_properties
client.on_avatar_name(fn)eventfn(list) — array of {uuid, name}
client.on_avatar_properties(fn)eventfn{avatar_id, about_text, first_life_text, born_on, charter_member, profile_url, partner_id, profile_image, mature_publish}

client.directory — search

PathKindReturns / payload
client.directory.search_people(text, start?)deferred-asyncquery_id; results via on_dir_people
client.directory.search_groups(text, start?) / .search_places(...)deferred-asyncquery_id; via on_dir_groups / on_dir_places
client.on_dir_people(fn)eventfn{query_id, results[]{id, first_name, last_name, online}}
client.on_dir_groups(fn) / client.on_dir_places(fn)event{id, name, members} / {id, name, dwell, for_sale}

client.grid — region lookup

PathKindReturns / payload
client.grid.find_region(name)deferred-asyncresult via on_region_info
client.on_region_info(fn)eventfn{name, x, y, handle, agents, map_image_id}

client.objects

PathKindReturns / payload
client.objects.list()snapshotarray of prim (CurrentSim cache)
client.objects.avatars()snapshotarray of {local_id, id, name, position, rotation}
client.objects.get(local_id)snapshotprim or nil
client.objects.request(local_id)command
client.objects.request_properties(local_id)deferred-asyncprops via on_object_properties
client.objects.rez{position,scale,rotation,group,physical?,temporary?}commandphysical/temporary set the prim flags; physical prims rez unselected
client.objects.select(local_id) / .deselect(local_id)command
client.objects.set_position(id, {x,y,z}) / .set_rotation(id, {x,y,z,w})command
client.objects.set_scale(id, {x,y,z}, child_only?, uniform?) / .set_name(id, name)command
client.objects.link(id[]) / .delink(id[])command
client.on_object_update(fn)eventfn(prim + {is_new, is_attachment})
client.on_object_properties(fn)eventfn{id, name, description, owner_id, group_id, creator_id, sale_price, sale_type, last_owner_id}
client.on_kill_object(fn)eventfn(local_id)

prim = {local_id, id, owner_id, group_id, parent_id, flags, text, name, description, position, scale, rotation}

client.inventory

PathKindReturns / payload
client.inventory.root()snapshotroot folder uuid or nil
client.inventory.items(folder_uuid)snapshotarray of node; unknown folder → {}
client.inventory.fetch(folder_uuid)deferred-asyncresult via on_folder_updated
client.inventory.give(item_uuid, name, asset_type, recipient_uuid)command
client.inventory.create_folder(parent_uuid, name)commandnew folder uuid
client.inventory.move_item(item_uuid, folder_uuid)command
client.inventory.rez(item_uuid, {x,y,z}, {x,y,z,w})commandtx uuid
client.inventory.derez(local_id)command
client.on_item_received(fn) / client.on_folder_updated(fn)eventfn(node) / fn(folder_uuid)

node = {uuid, name, parent_uuid, owner_id, kind} where kind is "item" or "folder"; items also carry {asset_uuid, asset_type, inventory_type}.

client.parcels

PathKindReturns / payload
client.parcels.current()snapshotparcel or nil
client.parcels.request_properties(local_id, seq?)deferred-asyncvia on_parcel_properties
client.parcels.request_info(parcel_uuid) / .request_all()deferred-asyncrequest_all scans the sim
client.parcels.return_objects(local_id, type, owner_uuid[])command
client.parcels.terraform(local_id, action, brush?)command
client.on_parcel_properties(fn)eventfn(parcel + {sequence_id, result})

parcel = {local_id, name, description, owner_id, group_id, area, max_prims, sale_price, flags, aabb_min, aabb_max}

client.groups

PathKindReturns / payload
client.groups.request_current()deferred-asyncvia on_current_groups
client.groups.members(group_uuid)deferred-asyncrequest_id; via on_group_members
client.groups.activate(uuid) / .join(uuid) / .leave(uuid)command
client.groups.invite(group_uuid, agent_uuid, role_uuid[])command
client.groups.join_chat(group_uuid)deferred-asyncvia on_group_chat_joined
client.groups.send_chat(group_uuid, message) / .leave_chat(group_uuid)commandmust join first
client.on_current_groups(fn)eventfn(array of group)
client.on_group_members(fn)eventfn{request_id, group_id, members[]{id, title, online_status, is_owner, contribution}}
client.on_group_chat_joined(fn)eventfn{session_id, session_name, success}; messages arrive via on_instant_message with group=true

group = {id, name, insignia_id, charter, member_count, money, open_enrollment}

Worked example

Every binding here is live, and every line touches only the client contract — so the whole script runs unchanged on the viewer once it implements the same surface.

-- greeter: auto-accept friends, greet, sit with recovery, click menus
local me = client.self.info()

client.on_friendship_offered(function(o)
  client.friends.accept(o.from_agent_id, o.session_id)
  client.instant_message(o.from_agent_id, "Hi " .. o.from_name .. "!")
end)

client.on_alert(function(a) print("[alert] " .. a.message) end)

-- sit with a result; ok=false carries "timeout" or the server alert text
client.self.sit(TARGET, function(ok, err)
  if ok then print("seated") else client.self.walk_to(128, 128, 22) end
end)

-- answer a blue-menu dialog by clicking the first button
client.on_script_dialog(function(d)
  client.self.reply_dialog(d.channel, 0, d.buttons[1], d.object_id)
end)

-- nearby-avatar radar
client.on_nearby(function(r) print(#r.avatars .. " avatars nearby") end)

-- anything untyped is still reachable raw
client.on_message("MeanCollisionAlert", function(pkt) print("bumped") end)

Raw packets — the message template

Beyond the typed surface, client.on_message / client.send_message reach any of the ~483 classic-UDP packets by name. The reference for every one is data/message_template.msg in the bot repo (the Linden message template) — it generates the PacketType enum and the packet classes in Packets.g.cs. A packet's block layout in that file is the table shape the hatch gives and takes.

Reading a template entry

Each packet is a header line then blocks; each block is a name + arity then its fields:

ImprovedInstantMessage Low 254 NotTrusted Zerocoded   -- name, freq, id, trust, encoding
{
    AgentData Single          -- Single  → pkt.AgentData = { … }
    {   AgentID   LLUUID  }
    {   SessionID LLUUID  }
}
{
    MessageBlock Single
    {   ToAgentID LLUUID     }
    {   Dialog    U8         }   -- IM type
    {   Message   Variable 2 }
    …
}

Field types marshal both ways: LLUUID ↔ lowercase string · U8…U64 / S8…S32 / F32 / F64 ↔ number · BOOL ↔ boolean · Variable N ↔ string (bytes; a single trailing NUL is stripped on read) · Fixed N ↔ N raw bytes as a string · LLVector3{x,y,z} · LLQuaternion{x,y,z,w}.

Two rules before you send. Most client→sim packets carry an AgentData block — fill AgentID/SessionID from client.self.info() (agent_id, session_id); some add a TransactionID (a fresh or zero uuid). And the trust flag matters: a Trusted packet sent by a client is dropped by the sim — those you can only receive.

Setting up a receiver

client.on_message("AvatarAnimation", function(pkt)
  local who = pkt.Sender.ID                    -- Single block
  for _, a in pkt.AnimationList do             -- Variable block → array
    print(who .. " playing " .. a.AnimID)
  end
end)

Building one to send

-- a raw IM: the typed client.instant_message only sends Dialog 0;
-- the packet lets you set any Dialog (offers, lures, group invites, typing…)
local me = client.self.info()
client.send_message("ImprovedInstantMessage", {
  AgentData = { AgentID = me.agent_id, SessionID = me.session_id },
  MessageBlock = {
    FromGroup = false, ToAgentID = TARGET,
    ParentEstateID = 0, RegionID = "00000000-0000-0000-0000-000000000000",
    Position = me.position, Offline = 0,
    Dialog = 0,                                -- 0 = normal IM
    ID = "00000000-0000-0000-0000-000000000000", Timestamp = 0,
    FromAgentName = me.name, Message = "hi (raw)", BinaryBucket = "",
  },
})

Packets worth reaching for

The ones without a typed binding (or where raw gives you more). Full field lists are in the template block for each.

PacketDirForKey blocks → fields
MeanCollisionAlertrecvcollisions & pushes against youMeanCollision[]{Victim, Perp, Time, Mag, Type} (Type 0–5: Bump, Push, Selected, Scripted, Physical)
AvatarAnimationrecvwhich animations an avatar is runningSender{ID} + AnimationList[]{AnimID, AnimSequenceID}
ViewerEffectsendlook-at / point-at / beam / selection effectsAgentData + Effect[]{ID, AgentID, Type, Duration, Color, TypeData}TypeData is a packed binary blob per effect type
SoundTriggerbothplay / hear a one-shot spatial soundSoundData{SoundID, OwnerID, ObjectID, ParentID, Handle, Position, Gain}
ObjectGrab / ObjectDeGrabsendtouch & release an object (raw form of self.touch)AgentData + ObjectData{LocalID, GrabOffset} + SurfaceInfo[]
RequestObjectPropertiesFamilyObjectPropertiesFamilysend→recvcheap owner / group / name / price / perms for one objectreply ObjectData{ObjectID, OwnerID, GroupID, *Mask, SaleType, SalePrice, Category, LastOwnerID, Name, Description}
GenericMessagebothextensible method dispatch — many features ride thisAgentData + MethodData{Method, Invoice} + ParamList[]{Parameter} (params are strings)
EstateOwnerMessagesendestate / region ops: kick, ban, restart, teleport-home (god / estate owner)same shape as GenericMessage; Method names the op, ParamList the args
ImprovedInstantMessagebothIM with any Dialog — inventory offers, teleport lures, group invites/notices, typingAgentData + MessageBlock{ToAgentID, Dialog, Message, BinaryBucket, …}

Everything else — all ~483 names, every field — is in data/message_template.msg. List the names with awk '$2 ~ /^(Low|Medium|High|Fixed)$/{print $1}' data/message_template.msg; the block layout under a name is exactly the pkt table for it.

Binding source: LibreMetaverse.LuauBot/ClientBindings*.cs; packets: data/message_template.msg. Change a binding here first, keep both hosts in step.