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
| Kind | Meaning |
| command | Sends / acts; returns immediately (some return an id or handle). |
| snapshot | Reads host-held state, copied into a fresh Luau table — never a live handle. |
| query | Returns a simple host value inline (a bool, a number). |
| event | client.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-async | The result does not return inline — it arrives later through a named on_* event. The worker is never blocked on the round-trip. |
| completion | Two 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:
| Tool | Args | Does |
bots_list | — | Ids 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
| Path | Kind | Returns / payload |
client.login{first,last,password,channel,uri,start} | command | true; non-blocking, outcome via on_login_progress. |
client.logout() | command | — |
client.connected() | query | bool |
client.chat(message, channel?, type?) | command | channel=0, type="normal" |
client.instant_message(uuid, message) | command | — |
client.on_login_progress(fn) | event | fn(status, code, message, fail_reason) |
client.on_disconnect(fn) | event | fn(reason, message) |
client.on_chat(fn) | event | fn{message, from_name, source_id, owner_id, type, type_name, source_type, audible, position} |
client.on_instant_message(fn) | event | fn{message, from_name, from_agent_id, to_agent_id, session_id, dialog, dialog_name, group} |
client.self — movement & actions
| Path | Kind | Returns / 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-async | result via on_teleport |
client.self.touch(local_id) | command | — |
client.self.sit(uuid, offset?, fn(ok,err)?) | completion | correlated to AvatarSitResponse, 10s timeout |
client.self.stand() | command | — |
client.self.walk_to(x, y, z?) | command | region-local autopilot |
client.self.animate(uuid) / .animation_stop(uuid) | command | — |
client.self.fly(on?) / .jump(on?) | command | on=true |
client.self.set_home() / .balance() | command / snapshot | balance → L$ integer |
client.self.request_balance() | deferred-async | result 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) | command | incoming offer via on_instant_message |
client.self.reply_dialog(channel, button_index, button_label, object_id) | command | answer a blue menu |
client.self.answer_permissions(item_id, task_id, mask) | command | grant script permission bits |
client.self.attach(item_uuid, point?) / .detach(item_uuid) | command | wear / remove an inventory item |
client.on_teleport(fn) | event | fn{message, status, status_code} |
client.on_alert(fn) | event | fn{message, notification_id} |
client.on_script_dialog(fn) | event | fn{message, object_name, object_id, owner_id, channel, buttons[]} |
client.on_script_question(fn) | event | fn{task_id, item_id, object_name, owner_name, permissions} |
client.on_money_balance(fn) | event | fn{balance, success, description, transaction_id} |
client.on_sit_response(fn) | event | fn{object_id, autopilot, force_mouselook, position, rotation} |
client.on_nearby(fn) | event | fn{avatars[]{id, position}, entered[], left[]} — nearby-avatar radar |
client — timers
| Path | Kind | Returns / payload |
client.set_interval(seconds, fn) | command | handle; calls fn on the worker every seconds. Composes — multiple intervals coexist. |
client.clear_interval(handle) | command | stops 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.
| Path | Kind | Returns / payload |
client.on_message(type_name, fn) | event | fn(packet_table) — per-type opt-in |
client.send_message(type_name, table) | command | builds + sends |
client.friends
| Path | Kind | Returns / payload |
client.friends.request(uuid) | command | — |
client.friends.accept(from_uuid, session_uuid) / .decline(...) | command | — |
client.friends.remove(uuid) | command | — |
client.friends.list() | snapshot | array of friend |
client.on_friendship_offered(fn) | event | fn{from_agent_id, from_name, session_id, message} |
client.on_friendship_response(fn) | event | fn{agent_id, name, accepted} |
client.on_friend_online(fn) / .on_friend_offline(fn) | event | fn(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
| Path | Kind | Returns / payload |
client.avatars.request_name(uuid) / .request_names(uuid[]) | deferred-async | result via on_avatar_name |
client.avatars.request_properties(uuid) | deferred-async | result via on_avatar_properties |
client.on_avatar_name(fn) | event | fn(list) — array of {uuid, name} |
client.on_avatar_properties(fn) | event | fn{avatar_id, about_text, first_life_text, born_on, charter_member, profile_url, partner_id, profile_image, mature_publish} |
client.directory — search
| Path | Kind | Returns / payload |
client.directory.search_people(text, start?) | deferred-async | query_id; results via on_dir_people |
client.directory.search_groups(text, start?) / .search_places(...) | deferred-async | query_id; via on_dir_groups / on_dir_places |
client.on_dir_people(fn) | event | fn{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
| Path | Kind | Returns / payload |
client.grid.find_region(name) | deferred-async | result via on_region_info |
client.on_region_info(fn) | event | fn{name, x, y, handle, agents, map_image_id} |
client.objects
| Path | Kind | Returns / payload |
client.objects.list() | snapshot | array of prim (CurrentSim cache) |
client.objects.avatars() | snapshot | array of {local_id, id, name, position, rotation} |
client.objects.get(local_id) | snapshot | prim or nil |
client.objects.request(local_id) | command | — |
client.objects.request_properties(local_id) | deferred-async | props via on_object_properties |
client.objects.rez{position,scale,rotation,group,physical?,temporary?} | command | physical/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) | event | fn(prim + {is_new, is_attachment}) |
client.on_object_properties(fn) | event | fn{id, name, description, owner_id, group_id, creator_id, sale_price, sale_type, last_owner_id} |
client.on_kill_object(fn) | event | fn(local_id) |
prim = {local_id, id, owner_id, group_id, parent_id, flags, text, name, description, position, scale, rotation}
client.inventory
| Path | Kind | Returns / payload |
client.inventory.root() | snapshot | root folder uuid or nil |
client.inventory.items(folder_uuid) | snapshot | array of node; unknown folder → {} |
client.inventory.fetch(folder_uuid) | deferred-async | result via on_folder_updated |
client.inventory.give(item_uuid, name, asset_type, recipient_uuid) | command | — |
client.inventory.create_folder(parent_uuid, name) | command | new folder uuid |
client.inventory.move_item(item_uuid, folder_uuid) | command | — |
client.inventory.rez(item_uuid, {x,y,z}, {x,y,z,w}) | command | tx uuid |
client.inventory.derez(local_id) | command | — |
client.on_item_received(fn) / client.on_folder_updated(fn) | event | fn(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
| Path | Kind | Returns / payload |
client.parcels.current() | snapshot | parcel or nil |
client.parcels.request_properties(local_id, seq?) | deferred-async | via on_parcel_properties |
client.parcels.request_info(parcel_uuid) / .request_all() | deferred-async | request_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) | event | fn(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
| Path | Kind | Returns / payload |
client.groups.request_current() | deferred-async | via on_current_groups |
client.groups.members(group_uuid) | deferred-async | request_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-async | via on_group_chat_joined |
client.groups.send_chat(group_uuid, message) / .leave_chat(group_uuid) | command | must join first |
client.on_current_groups(fn) | event | fn(array of group) |
client.on_group_members(fn) | event | fn{request_id, group_id, members[]{id, title, online_status, is_owner, contribution}} |
client.on_group_chat_joined(fn) | event | fn{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 }
…
}
Single → a {fields} table at pkt.<Block>.
Multiple / Variable → a 1-based array of {fields} (like MeanCollision), even when there's one entry.
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.
| Packet | Dir | For | Key blocks → fields |
MeanCollisionAlert | recv | collisions & pushes against you | MeanCollision[]{Victim, Perp, Time, Mag, Type} (Type 0–5: Bump, Push, Selected, Scripted, Physical) |
AvatarAnimation | recv | which animations an avatar is running | Sender{ID} + AnimationList[]{AnimID, AnimSequenceID} |
ViewerEffect | send | look-at / point-at / beam / selection effects | AgentData + Effect[]{ID, AgentID, Type, Duration, Color, TypeData} — TypeData is a packed binary blob per effect type |
SoundTrigger | both | play / hear a one-shot spatial sound | SoundData{SoundID, OwnerID, ObjectID, ParentID, Handle, Position, Gain} |
ObjectGrab / ObjectDeGrab | send | touch & release an object (raw form of self.touch) | AgentData + ObjectData{LocalID, GrabOffset} + SurfaceInfo[] |
RequestObjectPropertiesFamily → ObjectPropertiesFamily | send→recv | cheap owner / group / name / price / perms for one object | reply ObjectData{ObjectID, OwnerID, GroupID, *Mask, SaleType, SalePrice, Category, LastOwnerID, Name, Description} |
GenericMessage | both | extensible method dispatch — many features ride this | AgentData + MethodData{Method, Invoice} + ParamList[]{Parameter} (params are strings) |
EstateOwnerMessage | send | estate / region ops: kick, ban, restart, teleport-home (god / estate owner) | same shape as GenericMessage; Method names the op, ParamList the args |
ImprovedInstantMessage | both | IM with any Dialog — inventory offers, teleport lures, group invites/notices, typing | AgentData + 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.