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.
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.
| Event | Fires when… |
|---|---|
on LOAD | the script is loaded or reloaded (e.g. on app start, or after you enable/edit it). Use it to set defaults. |
on TEXT | a message arrives in any buffer. The handler can read, rewrite, or suppress the line before it is shown, see the pipeline note below. |
on ACTION | like TEXT, but for /me actions. |
on INPUT | you send a line, before it leaves. Same rewrite/halt pipeline, applied to your outgoing text. |
on NUMERIC | a 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 SIGNAL:NAME | a 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
}
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 TEXT, ACTION and NUMERIC: on TEXT:*help* only runs when the text matches. The filter always tests the text, so filter a numeric by testing $code in the body as above.
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).
| Command | Effect |
|---|---|
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 %x | remove the variable |
inc %x/dec %x | increment/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:
| Field | Value |
|---|---|
$me | your current nick |
$nick | the sender of the message/event |
$chan/$buffer/$target | the buffer the event belongs to (channel or query) |
$network | the network id |
$text | the message text (in on TEXT) |
$isme | true if the message is your own, else false |
$code | the three digit numeric (in on NUMERIC), empty for server text that carries none |
$whois | true 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:
| Token | Meaning |
|---|---|
$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).
| Command | Description | Example |
|---|---|---|
echo | print a local line to a buffer (not sent to the server) | echo $chan loaded ok |
msg | send a message to a target | msg #room hello all |
raw | send a raw IRC line to the server | raw WHO #room |
rewrite | (in on TEXT, ACTION, INPUT or NUMERIC) replace the line | rewrite $upper($text) |
set/unset/inc/dec/push/setat | variable operations (see above) | setat %score $nick 0 |
signal | raise SIGNAL:NAME with optional args | signal refresh $chan |
timer | raise a signal after a delay (ms, at least 20), the key to non-blocking work | timer 500 refresh |
http.get/http.post | make a web request, deliver the result to a signal (see HTTP) | http.get $url done $chan |
media.pick | ask the user to choose a file, answer on a signal (see Files) | media.pick -m image/* got $chan |
media.upload | upload a picked file to a URL | media.upload $url $mediatoken done $chan |
view { … } | build and mount an interactive screen (see Views) | view { text "Hi" bold } |
toast | show a brief on-screen notice | toast Saved |
halt/return | stop the current handler (return may yield a value) | halt |
<namespace>.<method> | call a host capability, e.g. the age.* transport | age.send $chan move 5 |
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
| Function | Returns |
|---|---|
$len(s) | length of a string |
$lower(s)/$upper(s) | case conversion |
$left(s,n)/$right(s,n) | first/last n characters |
$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)/$int(n)/$abs(n) | modulo/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 |
$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
| Function | Returns |
|---|---|
$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 |
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.
; 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:
| Flag | Effect |
|---|---|
-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 %var | take 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/function | Value |
|---|---|
$httpok | true if the request succeeded |
$httpstatus | HTTP status code |
$httpbody | the raw response body, cut off after about 1 MB |
$httplocation | the Location header, or empty. Upload endpoints answering 201 Created put the new URL here rather than in the body |
$httperror | why 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 }
}
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.
| Field | Value |
|---|---|
$mediaok | true if a file was chosen, false if you declined |
$mediatoken | the opaque handle to pass to media.upload |
$medianame | file name as shown to you |
$mediamime | MIME type |
$mediasize | size 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.
$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
| Element | What 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 that raises on SIGNAL:<actionId> |
card "Ah" [red] [back] | a playing card (rank+suit, optionally face-down) |
image "<url>" | a remote image |
spacer | flexible 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 }
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).
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
.hexfrom 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.
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.