Overview

HexDroid keeps the app itself tiny with a script interpreter, a UI renderer, and the crypto primitives, and letting behaviour arrive as user-loadable .hex scripts. A script can react to incoming messages, add slash-style commands, call out to the web, schedule work, and even paint a full interactive screen.

The language is small and mIRC-flavoured, but it is its own thing. If you have written an mIRC remote or an aliases file before, most of this will feel familiar; if not, the examples below are enough to start from scratch.

Sandboxed by design. Every script runs under a bounded budget (an instruction and time limit per dispatch) and can only reach the network through http.get/http.post/media.upload, which the app gates, and not at all while a network that uses a proxy is connected. There is no filesystem access: the one way a script sees a file is media.pick, where HexDroid asks first and you choose the file yourself. There is one generic view renderer, so a script describes a screen, it never ships native code.

Scripts are in Menu > Scripts. The bundled scripts shipped with HexDroid are disabled by default, you opt them in. You can also paste, import, edit, and revert scripts from there; see Installing & editing.

Anatomy of a script

A .hex file is a flat list of two kinds of block:

  • on <event> { … }, an event handler that runs when something happens.
  • alias <name> { … }, a reusable command you can call from anywhere (and that the user can run as /name).

Inside a block, statements are separated by a newline or a pipe |. Comments start with ; and run to the end of the line.

; greeter.hex, answer when someone mentions you
on TEXT {
  if ($contains($text, $me) == true) {
    msg $chan hi $nick, you mentioned me     ; newline or | separates statements
  }
}

alias wave { me waves at $1- }                ; usable as: /wave everyone

That is the whole shape of it: handlers react to events, aliases package up commands, and statements do the work. Everything else on this page is the vocabulary, the events you can hook, the variables you can read, the commands and functions you can call, and the view DSL for drawing screens.

Events

An on <event> block registers a handler. Event keys are case-insensitive.

EventFires when…
on LOADthe script is loaded or reloaded (e.g. on app start, or after you enable/edit it). Use it to set defaults.
on TEXTa message arrives in any buffer. The handler can read, rewrite, or suppress the line before it is shown, see the pipeline note below. It doesn't fire for lines you send from this device (use on INPUT); your own lines sent from another client, such as through a bouncer, arrive with $isme true.
on ACTIONlike TEXT, but for /me actions.
on INPUTyou press send in the input box, before the line is processed. Same rewrite/halt pipeline, applied to what you typed, /commands included ($iscmd is true for those). Lines sent by scripts, buttons and menus don't trigger it.
on NUMERICa server line is about to be printed. $code holds the numeric and $whois is true when the line answers a WHOIS you ran. The same rewrite/halt pipeline applies, so this is how you filter server output such as WHOIS replies.
on JOINsomeone joins a channel, you included ($isme). $nick, $chan, and $account when the server reports it.
on PARTsomeone leaves a channel. $text is their part message.
on QUITsomeone disconnects from the network. $text is the quit message; $chan is empty, since a quit isn't tied to one channel.
on KICKsomeone is kicked. $nick is who kicked, $victim who was kicked, $text the reason; $isme is true when you were kicked.
on MODEsomeone's status in a channel changes (op, voice, halfop and so on), one event per change. $mode is the change such as +v or -o, $victim whose status changed, $nick who set it; $isme is true when it's your status.
on OP, DEOP, VOICE, DEVOICE, HELP, DEHELPthe matching status change, alongside on MODE. $victim (also $opnick, $vnick, $hnick) is who changed, $nick who set it.
on BAN, on UNBANa ban is set or removed. $banmask is the mask, $nick who set it.
on RAWMODEany channel MODE line, once. $text is the modes with their arguments, such as +bv *!*@host nick.
on TOPICsomeone changes the topic. $text is the new topic.
on INVITEyou're invited. $nick invited you to $chan. echo $chan would open that channel's buffer; use echo -a to write to the one you're in.
on CTCPa CTCP request (not /me). $text is the command and arguments, as in VERSION or PING 1234. HexDroid still sends its own replies to the ones it knows.
on CTCPREPLYa reply to a CTCP you sent. $text is the command and reply.
on SNOTICEa server notice, alongside on NOTICE.
on NOTIFY, on UNOTIFYsomeone on your /monitor list comes online or goes offline. $nick is who.
on APPACTIVEHexDroid comes to the front or goes to the background. $appactive is true or false.
on FILERCVD, on FILESENTa DCC transfer finishes. $filename; for received files also $filesize and $path.
on NICKsomeone changes nick: $nick is the old one, $newnick the new. $isme is true for your own change.
on NOTICEa notice arrives, including service replies such as NickServ's. $nick, $text, $isprivate, and $isserver for server notices. A private notice has no $chan, so echo writes to the buffer you're in. Notices from ignored nicks don't reach scripts.
on CONNECTyou finish connecting (registration completes) to a network. Handy for identifying or joining channels.
on DISCONNECTa connection that was up closes. $text is the reason. A failed connection attempt doesn't count.
on SIGNAL:NAMEa custom signal NAME is raised, by the signal command, a timer, a view button, or another script.

Capabilities raise signals too: the encrypted transport delivers messages as on SIGNAL:age_msg and dealt secrets as on SIGNAL:age_deal (see Encrypted transport). The same on SIGNAL:… form handles them.

The TEXT pipeline

Inside on TEXT you can change what the user actually sees:

  • rewrite <new text>, replace the line's text (e.g. to strip or annotate it).
  • halt, stop processing and drop the line entirely.
on TEXT {
  if ($contains($text, "spoiler") == true) { halt }   ; hide spoilers
  rewrite $replace($text, ":wave:", "👋")       ; expand a shortcode
}

In on INPUT they act on your own line before it goes anywhere: rewrite changes what is sent, and halt stops it being sent at all. Test $iscmd to leave /commands alone:

on INPUT {
  if ($iscmd == true) { return }                        ; leave /commands alone
  if ($contains($text, "hunter2") == true) {            ; never send this by accident
    echo $chan *** not sent: that looks like your password
    halt
  }
  rewrite $replace($text, ":shrug:", "🤷")       ; expand a shortcode before it's sent
}

The same two statements work in on ACTION, on INPUT and on NUMERIC. In a NUMERIC handler, halt hides the line only, the client still acts on it internally, so dropping an end-of-MOTD or an error numeric changes nothing but the display:

on NUMERIC {
  if ($whois != true) { return }  ; leave the same codes alone outside a WHOIS
  if ($code == 344) { halt }     ; 344 RPL_WHOISCOUNTRY, don't want it
  if ($code == 350) { halt }     ; 350 RPL_WHOISISWEBIRC
}

An optional glob filter narrows an event: on TEXT:*help* only runs when the text matches. Channel events (JOIN, PART, KICK, MODE, the status events, BAN/UNBAN, RAWMODE, TOPIC and INVITE) filter on the channel instead, so on JOIN:#help runs only for that channel. Everything else filters on the text, so filter a numeric by testing $code in the body as above.

Headers pasted from mIRC work too: on *:TEXT:*hello*:#:{ is read as <level>:<event>:<match>:<target>:. The level (and prefixes such as @ or !) is ignored, since HexDroid has no user levels. The match text works like a filter and may contain spaces and braces; with a $ in the level it's a regex instead, as in on $*:TEXT:/^!seen (\S+)/i:#:{ or on $*:TEXT:/^\d{3}$/:#:{. The target is # for channels, ? for private messages, * for both, or a comma list such as #help,#dev. on *:SIGNAL:name: and on *:START: work too, as on SIGNAL:name and on LOAD. A handler for an event HexDroid never raises is reported in the script log when the script loads, rather than silently never running.

The IRC events only fire for live traffic, never for history replayed after a reconnect, so a bouncer's backlog doesn't greet everyone again. They report what happened; unlike TEXT, they can't rewrite or halt it:

on CONNECT { msg NickServ IDENTIFY %ns_pass }         ; identify as soon as you connect

on JOIN:#help {
  if ($isme == true) { return }
  notice $nick Welcome to #help, $nick. Ask away, someone will answer.
}

on NOTICE {
  if ($nick == NickServ && $contains($text, "You are now identified") == true) { join #private }
}

Variables & arguments

Global variables, %name

Variables prefixed with % hold strings, numbers, lists, or maps, and keep their value between events until the scripts are reloaded. They are shared by every loaded script and by every network: two scripts that both set %ep overwrite each other, and whichever on LOAD runs last wins. Give a script's settings a prefix of their own (%img_ep, %tr_ep).

CommandEffect
set %x <value>assign (omit the value to clear it)
set -l %x <value>assign a local that lives only for the current handler or alias call and hides a global of the same name. Use it for scratch values.
unset %xremove the variable
inc %x/dec %xincrement/decrement a numeric variable
push %list <value>append to a list
setat %coll <key> <value>set a key/index inside a map or list (nestable)

Built-in fields, $field

Inside a handler, these read-only fields describe the event:

FieldValue
$meyour current nick
$nickthe sender of the message/event
$chan/$buffer/$targetthe buffer the event belongs to (channel or query)
$networkthe network id
$textthe message text (in on TEXT)
$ismetrue when the event is about you: your own line (from another client, in on TEXT), your own join, part, quit or nick change, or your being kicked
$isprivatetrue for a private message or notice, false in a channel
$iscmdtrue in on INPUT when the line is a /command
$victim, $mode, $newnick, $account, $isserverin on KICK, on MODE, on NICK, on JOIN and on NOTICE, see Events
$ctimethe current Unix time, in seconds
$ticksmilliseconds from a steady clock, for measuring how long something took. Only differences between two readings mean anything.
$timethe local time as HH:mm:ss
$screen.landscape, $screen.width, $screen.height, $screen.tvthe display, for choosing a view layout: 1/0 for landscape and TV, width and height in dp
$codethe three digit numeric (in on NUMERIC), empty for server text that carries none
$whoistrue when the line (in on NUMERIC) answers a WHOIS you ran

Positional arguments, $1, $2-

When you call an alias, or raise a signal with extra words, or attach args to a view button, those words bind to numbered arguments:

TokenMeaning
$1, $2, …the first, second, … argument
$2-every argument from the second onward, space-joined
$1-all arguments, space-joined

Expansion is recursive: %vars, $fields, and $func(…) calls can all be nested inside each other.

alias greet {
  set -l %who $1                    ; first argument, local to this call
  msg $chan Hello %who, and also $2-   ; mix args, vars, and rest
}

Commands

A statement is a command followed by arguments. User aliases are commands too, and a fixed set of IRC commands can be run as well (see below).

CommandDescriptionExample
echoprint a local line to a buffer (not sent to the server). The first word is the buffer: -a means the one you're in, -s the server buffer, and a variable that's empty (like $chan in on QUIT) also means the one you're in.echo $chan loaded ok
msgsend a message to a target. If the target is a variable that's empty, nothing is sent.msg #room hello all
rawsend a raw IRC line to the serverraw WHO #room
rewrite(in on TEXT, ACTION, INPUT or NUMERIC) replace the linerewrite $upper($text)
set/unset/inc/dec/push/setatvariable operations (see above)setat %score $nick 0
signalraise SIGNAL:NAME with optional argssignal refresh $chan
timerraise a signal after a delay (ms, at least 20), the key to non-blocking worktimer 500 refresh
http.get/http.postmake a web request, deliver the result to a signal (see HTTP)http.get $url done $chan
media.pickask the user to choose a file, answer on a signal (see Files)media.pick -m image/* got $chan
media.uploadupload a picked file to a URLmedia.upload $url $mediatoken done $chan
view { … }build and mount an interactive screen (see Views)view { text "Hi" bold }
toastshow a brief on-screen noticetoast Saved
halt/returnstop the current handler (return may yield a value)halt
<namespace>.<method>call a host capability, e.g. the age.* transportage.send $chan move 5
A script can also run these app commands, in the buffer its event came from: me, join, part, cycle, topic, mode, invite, kick, knock, names, who, whois, whowas, list, notice, ctcp, nick, away, back, op, deop, voice, devoice, ban, unban, ignore and unignore. Commands that would drop or move your connection, such as quit or server, are left out on purpose; raw remains for a script that really means it. Any other unknown verb is reported in the buffer and skipped.

Value functions

Functions are written $name(arg, arg, …) and return a value you can use anywhere a value is expected.

Strings & math

FunctionReturns
$len(s)length of a string
$lower(s)/$upper(s)case conversion
$left(s,n)/$right(s,n)first/last n characters. A negative n counts from the other end, as in mIRC: $left(s,-2) is all but the last two characters, $right(s,-2) all but the first two
$substr(s,start,len)substring
$replace(s,find,with)replace all occurrences
$trim(s)strip surrounding whitespace
$contains(s,sub)/$indexof(s,sub)membership test/position
$repeat(s,n)repeat a string
$calc(expr)evaluate an arithmetic expression
$mod(a,b)/$idiv(a,b)/$int(n)/$abs(n)modulo/floor division/floor/absolute value
$round(n,dp)/$ceil(n)/$pow(a,b)/$clamp(n,lo,hi)rounding, powers and bounds
$rand(n)/$rand(lo,hi)a random integer from 0 to n-1, or from lo to hi inclusive. Fine for games; for anything that must be unguessable, use $age.rand
$min(…)/$max(…)smallest/largest of the arguments
$pad(s,n)/$padleft(s,n)pad to width n on the right/left
$capitalize(s)/$title(s)upper-case the first letter/every word's first letter
$re_match(s,re)/$re_find(s,re)/$re_group(s,re,n)/$re_replace(s,re,with)regular expressions: test, first match, a capture group, replace all
$urlencode(s)percent-encode for a URL or form body
$json(body,path)a value from JSON text by dotted path, e.g. results.0.url
$setting(key)read a host setting (e.g. $setting(applang))

Lists & maps

FunctionReturns
$list(a,b,…)build a list
$map(k,v,k,v,…)build a map from key/value pairs
$get(coll,key)element by index, or value by key
$has(coll,key)true if the key/value is present
$keys(map)/$values(map)the keys/values of a map
$len(coll)element count
$split(s,sep)/$join(list,sep)string ↔ list
$sort(list)/$reverse(list)sorted/reversed copy
$slice(list,from,to)sub-list
$concat(a,b,…)join lists end to end
$find(list,x)/$count(list,x)index of/number of occurrences
$sum(list)numeric total
$range(lo,hi)a list of integers from lo to hi
$pick(list)/$shuffle(list)a random element/a shuffled copy
$tojson(value)serialise a list, map or scalar to JSON, ready for http.post -b. A string JSON reads as a number, such as 12 or -0.5, is written unquoted; 007 and 1e5 stay strings. Control characters, IRC colour and bold codes included, are escaped

Control flow

Conditions live in parentheses and combine with && and ||. Comparisons support ==, !=, <, >, <=, >=, plus isin (substring) and iswm (wildcard match).

if ($nick == ChanServ) {
  ; ignore services
} elseif (%warned > 3 && $isme == false) {
  msg $chan settle down
} else {
  inc %warned
}

foreach %p $keys(%score) {
  echo $chan %p has $get(%score, %p) points
}

while ($len(%queue) > 0) {
  ; ... process and shrink %queue ...
}

foreach <item> <collection> walks a list or a map's keys. halt stops the whole handler; return exits the current alias (optionally with a value).

HTTP & timers

Web requests are asynchronous: you name a signal to receive the result, plus any context words you want passed along.

Reloading scripts cancels everything still pending: a timer, request or file pick started before the reload never answers afterwards, so an edited script never receives its previous copy's callbacks.

; http.get  [flags] <url> <signal> [context...]
; http.post [flags] <url> <body> <signal> [context...]
http.post https://libretranslate.example/translate q=$urlencode($text)&source=auto&target=en done $chan

Flags go before the URL:

FlagEffect
-t <type>set Content-Type instead of letting the body's shape decide
-h <name:value>add a request header; repeat for more than one
-b %vartake the body whole from a variable, spaces and quotes included

Flag values are read before argument expansion, so a %var in one arrives as that variable's whole value rather than the words the argument splitter would otherwise break it into. That is what makes -b the way to send JSON: the positional body is a single space-delimited word, so anything containing a space has to come through -b. A header value with spaces in it (a bearer token, say) goes the same way.

set %auth Authorization: Bearer $2
set %payload $tojson($map(text, $1-, lang, irc))
http.post -t application/json -h %auth -b %payload https://paste.example/api done $chan

Without -t, the content type is inferred from the body: one starting { or [ is sent as JSON, one shaped like key=value&key=value is sent form-urlencoded, and anything else goes as text/plain.

In the receiving handler, the response is available through these fields, and $json(body, path) pulls a value out of a JSON reply:

Field/functionValue
$httpoktrue if the request succeeded
$httpstatusHTTP status code
$httpbodythe raw response body, cut off after about 1 MB
$httplocationthe Location header, or empty. Upload endpoints answering 201 Created put the new URL here rather than in the body
$httperrorwhy the request never got a status when $httpstatus is 0: blocked by policy, unreachable, a file too large, and so on
$json(body, path)a field from a JSON body by dotted path: translatedText, detectedLanguage.language, results.0.filePath
on SIGNAL:done {
  if ($httpok == true) {
    echo $1 ↳ $json($httpbody, translatedText)   ; $1 = the buffer we passed as context
  }
  else { echo $1 *** request failed ($httpstatus) $httperror }
}
Use timer for anything repetitive or heavy. Every handler, including timer and HTTP callbacks, runs on the UI thread, one at a time, so don't loop a screen-repaint synchronously. Schedule the next step with timer <ms> <signal> instead, each tick yields, the UI stays responsive, and you avoid the "app not responding" trap.

Picking & uploading files

A script has no filesystem access and cannot name a path. The only way it sees a file is if you hand it one:

; media.pick   [-m <mime>] <signal> [context...]
; media.upload [-h <name:value>] [-p <name=value>] [-f <field>] [-r] <url> <token> <signal> [context...]
media.pick -m image/* picked $chan

media.pick does not open anything by itself. HexDroid shows a prompt naming the channel and network the script is running in, and only if you agree does the system file picker appear. The script is then handed a token for that one file, never a path, and can reach nothing else on the device. The pick's signal goes only to the script that asked, and only that script can upload with the token. Tokens last until scripts are reloaded.

FieldValue
$mediaoktrue if a file was chosen, false if you declined
$mediatokenthe opaque handle to pass to media.upload
$medianamefile name as shown to you
$mediamimeMIME type
$mediasizesize in bytes, or -1 when the provider doesn't say

media.upload sends the file to the URL without the bytes ever passing through the script. It POSTs multipart/form-data under field name file by default; -f <field> renames the field, -p <name=value> adds a text form field beside the file (repeatable, for an endpoint's own options), and -r sends the raw bytes as the request body instead, which is what a soju-style filehost wants. The body is prepared first so the request carries an exact Content-Length, which servers that read uploads by length (PHP among them) need. Files over 64 MB are refused. The reply carries the same fields an http.post does, $httplocation and $httperror included.

alias img { media.pick -m image/* picked $chan }

on SIGNAL:picked {
  if ($mediaok != true) { return }
  toast Uploading $medianame
  media.upload https://paste.example/img $mediatoken done $1
}

on SIGNAL:done {
  if ($httpok != true) { echo $1 upload failed ($httpstatus) $httperror | return }
  set -l %url $trim($httplocation)
  if ($len(%url) == 0) { set -l %url $trim($httpbody) }
  msg $1 %url
}

Redirects are not followed, so an endpoint answering with a 301 to a trailing slash or to an index.php reports that 301 rather than uploading. The error names where it wanted to send you, and that address is the one to use.

Check the body even on a 2xx: many upload endpoints answer 200 with JSON that describes a failure, so $httpok alone does not mean the file was stored.

Note there is no empty-string literal in .hex: %url == "" compares against a two-character string and is never true for a blank value. Test emptiness with $len(%url) == 0.

One pick at a time. A second request while the prompt is up is answered with $mediaok false rather than stacking dialogs, so a looping script cannot bury the app under choosers. After you decline, that script's further picks are refused without a prompt for a minute, unless you started them with a command. Redirects are not followed for an upload: a 3xx is reported instead of resending the file to a host the permission check never saw.

Interactive views

A view { … } block describes a screen using a small layout DSL, then mounts it. Rebuild and re-mount it whenever your state changes to "redraw". Buttons report taps back as signals, so a view plus a few on SIGNAL handlers is a complete little app.

Elements

ElementWhat it is
column { … }/row { … }vertical/horizontal stacks of children
stack { … }/ring { … }overlay children/arrange them in a circle
surface { … }a panel (background, padding, elevation, gradient)
text "…"a label
button "label" <actionId> [args]a tappable button. It runs the alias named <actionId> if there is one, otherwise raises on SIGNAL:<actionId>
card "Ah" [red] [back]a playing card (rank+suit, optionally face-down)
image "<url>"a remote image
spacerflexible empty space

Modifiers

Any element takes trailing modifiers: bold, fill, wrap (on a row, flow children onto extra lines instead of overflowing), circle, color <hex>, bg <hex>, bgimage <url>, gradient linear:<a>:<b>, textsize <sp>, width/height/size/radius <dp>, pad/gap <dp>, weight <n>, align <where>, border <hex> [w], offsetx/offsety <dp>, and elevation <dp>. On an image, the scale mode is crop (the default), fit, or stretch.

alias counter_render {
  view {
    surface bg #13243f radius 20 pad 16 {
      column gap 12 align center {
        text Counter bold color #ffffff textsize 18
        text %n color #8fd0ff textsize 40
        row gap 10 {
          button "-1" dec weight 1
          button "+1" inc weight 1
        }
        button "Reset" reset fill
      }
    }
  }
}

on LOAD          { set %n 0 }
on SIGNAL:inc    { inc %n | counter_render }
on SIGNAL:dec    { dec %n | counter_render }
on SIGNAL:reset  { set %n 0 | counter_render }
Closing a view. When the user closes a mounted screen, the engine raises SIGNAL:view_closed. Handle it to stop any timers or loops your view was driving, so a pending tick can't re-open it.

Encrypted transport (advanced)

Scripts that need to talk to other players or peers can use the age.* capability, the same Ed25519 + X25519 machinery behind +AGE. It provides identity (age.me), randomness and hashing (age.rand, age.sha), sending over a keyed channel (age.send), local loopback for solo/practice play (age.local), and sealing a secret to one recipient (age.seal). Inbound traffic arrives as on SIGNAL:age_msg (and dealt secrets as on SIGNAL:age_deal).

For running a table: age.host keys a channel as its host, $age.ready(chan) says whether it's keyed yet, $age.owner(chan) is the elected host (the lowest fingerprint present, the same on every client), and $age.members(chan) lists the other +AGE members present. age.close and age.purge tear a table down. The transport also raises on SIGNAL:age_peer when someone announces themselves on a channel, on SIGNAL:age_ready when a channel becomes keyed, and on SIGNAL:age_left when a member leaves by part, quit or kick ($from is their fingerprint, or yours when you leave or lose the connection).

The complete on-the-wire format, encoding, key derivation, sealed invites, the signed channel layer, and the 1:1 handshake and double ratchet, is published in the age-wire-format specification, so other clients can interoperate. For message-level chat encryption you do not need scripting at all, see the Encryption guide.

Worked examples

1 · A dice roller

Adds /roll (and /roll 20 for a d20).

alias roll {
  set -l %sides $1
  if ($len(%sides) == 0) { set -l %sides 6 }   ; default d6
  set -l %r $rand(1, %sides)                   ; 1 to %sides inclusive
  me rolls a %sides-sided die: %r
}

2 · Keyword highlighter

Locally flags lines that mention a watch-word, without touching the server.

on LOAD { set %kw_watch deploy }
on TEXT {
  if ($isme == false && $contains($lower($text), %kw_watch) == true) {
    echo $chan ⚠ watch-word from $nick
  }
}

3 · Auto-translate incoming lines

The pattern the bundled translate.hex uses: post each foreign line to a translation endpoint and echo the result underneath. Set your own endpoint and key at the top.

on LOAD {
  set %tr_lang $setting(applang)
  if ($len(%tr_lang) == 0) { set %tr_lang en }
  set %tr_ep https://libretranslate.example/translate   ; your endpoint
  set %tr_key                                            ; API key (blank = none)
}

on TEXT {
  if ($isme == false) {
    if ($len(%tr_key) > 0) { http.post %tr_ep q=$urlencode($text)&source=auto&target=%tr_lang&api_key=%tr_key tr $chan $text }
    else { http.post %tr_ep q=$urlencode($text)&source=auto&target=%tr_lang tr $chan $text }
  }
}

on SIGNAL:tr {
  if ($httpok == true) {
    set -l %out $json($httpbody, translatedText)
    if (%out != $2-) { echo $1 ↳ %out }   ; $1 = buffer, $2- = original text
  }
  else { echo $1 *** translate failed ($httpstatus) $httperror }
}

4 · Filter WHOIS replies

The pattern the bundled whoisfilter.hex uses: drop the numerics you never read. Editing one list is easier than remembering which code is which, so keep the comment next to it. The $whois test keeps the filter to WHOIS output, so a 301 away reply to a message you sent still shows.

on LOAD {
  set %wf_hide $list(310, 337, 339, 344, 350, 378, 379)
}

on NUMERIC {
  if ($whois != true) { return }
  if ($has(%wf_hide, $code) == true) { halt }
}

5 · Upload an image and paste the URL

The shape of the bundled imgpaste.hex. /img asks you for a file, uploads it, and sends the returned URL to the channel. It targets an endpoint that reads the file from an images field, strips photo metadata when strip_exif is set, and answers 200 with JSON either way, so the body decides success.

on LOAD {
  set %img_ep https://paste.example/img/index.php   ; exact URL, file name included
  set %img_base https://paste.example                ; the returned path hangs off this
}

alias img { media.pick -m image/* img_picked $chan }

on SIGNAL:img_picked {
  if ($mediaok != true) { return }
  toast Uploading $medianame
  media.upload -f images -p strip_exif=1 %img_ep $mediatoken img_done $1
}

on SIGNAL:img_done {
  if ($httpok != true) { echo $1 *** upload failed ($httpstatus) $httperror | return }
  set -l %err $json($httpbody, results.0.error)
  if ($len(%err) > 0) { echo $1 *** upload rejected: %err | return }
  set -l %path $json($httpbody, results.0.filePath)
  if ($len(%path) == 0) { echo $1 *** no path in the reply | return }
  msg $1 %img_base%path
}

6 · A scoreboard with a view

Tracks points per nick (/point nick) and shows a live board.

on LOAD { set %sb_score $map() }

alias point {
  if ($has(%sb_score, $1) == false) { setat %sb_score $1 0 }
  setat %sb_score $1 $calc($get(%sb_score, $1) + 1)
  board_render
}

alias board_render {
  view {
    surface bg #161b22 radius 16 pad 16 {
      column gap 8 {
        text Scoreboard bold textsize 18 color #ffffff
        foreach %p $sort($keys(%sb_score)) {
          row gap 8 { text %p weight 1 | text $get(%sb_score, %p) bold color #58a6ff }
        }
        button "Clear" clear fill
      }
    }
  }
}

on SIGNAL:clear { set %sb_score $map() | board_render }

Installing & editing

  • Enable/disable, bundled scripts ship disabled; flip the toggle to opt in. A disabled script never runs and never registers its commands or launchers.
  • Import/Paste, add your own .hex from a file or the clipboard.
  • Edit, opens a full-screen editor with line numbers. Save & reload writes it back and reloads immediately, so changes (like an endpoint or API key) take effect at once.
  • Revert, bundled scripts can be restored to their shipped default if an edit goes wrong.
  • Remove, delete a script you added.
To drop in an API key or point a script at your own server, open the script in the editor, change the marked set %… line near the top, and Save & reload. There is no separate settings field, the script is the configuration.

From here, browse the command reference for everything a script can drive, or the encryption guide for message-level E2EE that needs no scripting at all.