Add a command
This page walks through adding a WebSocket command to originchats-osl: planning the request and response, writing the handler, registering it, and testing it. Read Architecture first for how a command flows through the server.
Plan the wire contract
Section titled “Plan the wire contract”Before writing code, decide:
- the command name, in
snake_case - the request fields, their types and defaults, and which are required
- the direct response, and any event other clients receive
- the permission the command needs, and any per-resource rule (such as channel visibility or ownership)
- what it saves, and how it rolls back if the save fails
- whether clients need to discover it through the handshake
Use one command per operation. A partial-update command can take several optional fields, but it should reject a request that sets none of them.
Write the handler
Section titled “Write the handler”Create src/api/handlers/<group>/<command>.osl. The file name matches the command, the function is handle plus the command in PascalCase, and error frames use the command as src. This is webhook_create, trimmed slightly:
*schema.Schema schemaWebhookCreate = schema.object({ channel: schema.string().trim().minLen(1), name: schema.string().trim().minLen(1)})
def handleWebhookCreate(*ws.Connection conn, object msg) object[] ( string command = "webhook_create" auto err = (string text) object[] -> objErr(text, command)
object data = schemaWebhookCreate.safeParse(msg) catch ( return err(_.unwrapErr().message) ) object channelInfo = channelGet(data.channel).assertElse(object, {}) if channelInfo.len == 0 ( return err("Channel not found") )
object webhook = webhookCreate(data.channel, data.name, conn.Get("userid")) if webhook.error != null ( return err(`Failed to create webhook '${data.name}' in channel '${data.channel}'`) )
webhookAudit(conn, command, webhook, {channel: data.channel}) return [{cmd: command, webhook}])Rules for handlers:
- Take the connection and the decoded request, and return
object[]. - Return bad input as an error frame with
objErr. Don’t throw for user mistakes. A thrown value also becomes an error frame, but its text is whatever was thrown. - Validate with a
std:schemaschema first, then pass only validated values on. For partial updates, list the allowed keys so unknown fields can’t reach storage. - Don’t check login or the command-level permission in the handler. The router does that. See Set permissions.
If the group directory is new, add import "handlers/<group>" to the list at the top of src/api/index.osl. Importing a directory brings in the handler files inside it.
Put rules in the right layer
Section titled “Put rules in the right layer”The handler adapts the protocol. Shared rules belong elsewhere:
| Layer | Owns |
|---|---|
src/api/helpers/<area>/ |
Permission and validation rules, serialization, and behavior shared between handlers or with slash commands. |
src/db/<resource>/ |
File formats, locks, saving, and rollback. |
src/utils/ |
Response builders and broadcast helpers. |
src/api/index.osl |
Parsing, access checks, dispatch, listeners, and broadcast routing. |
Don’t add a helper that only wraps one call. Keep single-use logic in the handler unless it clearly belongs to a storage module.
Set permissions
Section titled “Set permissions”Command-level rules live in tables in src/api/access.osl:
| Table | Add the command when |
|---|---|
COMMAND_PERMISSIONS |
The whole command needs one permission, such as manage_server. |
COMMAND_ALTERNATIVES |
Any one of several permissions is enough. |
TIMEOUT_BLOCKED |
A timed-out user shouldn’t be able to run it. Include every command that writes or acts. |
METADATA_REFRESH_COMMANDS |
It can change what other users see about roles or permissions. |
UNAUTHENTICATED_COMMANDS |
It must work before login. This is rare. |
The owner and anyone with administrator pass every permission check. Only the literal owner role bypasses channel permission lists.
Per-resource rules stay in the helper for that resource: channel visibility (canUserView), editing your own message versus anyone’s, thread ownership, and role hierarchy (roleManageError).
Save before you succeed
Section titled “Save before you succeed”A mutation follows this order:
validate -> authorise -> normalise -> change and save -> audit or refresh -> respondKeep the previous in-memory value until the save succeeds. If it fails, put the old value back and return an error. Never broadcast a change that wasn’t saved.
Use the resource’s storage functions and the transaction helper (dbCommit) rather than writing files directly. See Persistence.
Admin actions should call auditRecord. Channel changes should call refreshChannels, which sends each connection channel_get or channels_get depending on what its client supports.
Register the command
Section titled “Register the command”Add one case to dispatchCmd in src/api/index.osl:
"webhook_create" -> handleWebhookCreate(conn, msg)An unknown command returns no frames, on purpose.
If clients need to discover the command, add its name to handshakeCapabilities() in src/api/handlers/auth/handshake.osl. Add it conditionally if the feature can be turned off, as server_side_embeds is.
Broadcast correctly
Section titled “Broadcast correctly”Mark a returned frame global: true when other connections should receive it. Include channel so the router only sends it to users who can see that channel and aren’t severed from the caller.
The router sends the caller its copy with the listener, then removes the listener and sends the frame to everyone else who qualifies. Don’t also send it yourself.
For events every logged-in user may see, use broadcastAuthed. Presence needs per-viewer filtering. Keep sensitive admin results direct.
Test it
Section titled “Test it”Add or extend a *.test.osl file next to the code, and run it with osl test. See Tests. If the command belongs in one of the access.osl tables, extend src/api/access.test.osl, which checks those tables.
Then try it against a real server built from your branch, and cover:
- the smallest valid request
- each validation error a client might hit
- a user without the permission, a user who has left, and a timed-out user
- resources the user can’t see or doesn’t own
- a failed save, and that memory is rolled back
- the listener on the direct response, and no listener on broadcasts
- who receives the broadcast
- reconnecting, and clients that don’t advertise the capability
Document it
Section titled “Document it”Add a page under src/content/docs/protocol/commands/ in the docs repository. Read the handler and every helper it calls, then document the request, response, broadcasts, permissions, and errors, quoting error text exactly. Write the page by hand; don’t generate it from source.
Update the command index and any concept or client guide the change affects, then build the docs.
