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), has no filesystem access, and can only reach the network through http.get/http.post, which the app gates. 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 it 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, say hello when someone joins your name to the channel
on TEXT {
  if ($contains($text, $me) == true) {
    msg $chan hi $nick, you mentioned me     ; newline or | separates statements
  }
}

alias wave { msg $chan /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.
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
}

Variables & arguments

Global variables, %name

Variables prefixed with % persist for the life of the script. They hold strings, numbers, lists, or maps.

CommandEffect
set %x <value>assign (omit the value to clear it)
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 if the message is your own, else false

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 %who $1                       ; first argument
  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; any unknown verb is forwarded to the normal slash-command pipeline.

CommandDescriptionExample
echoprint a local line to a buffer (not sent to the server)echo $chan loaded ok
msgsend a message to a targetmsg #room hello all
rawsend a raw IRC line to the serverraw WHO #room
rewrite(in on TEXT) replace the shown textrewrite $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), 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
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
Any verb the engine doesn't recognise is passed to the normal command pipeline, so a script can run app commands too, e.g. join #room or nick NewNick.

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
$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
$min(…)/$max(…)smallest/largest of the arguments
$urlencode(s)percent-encode for a URL or form body
$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

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 ($len(%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  <url> <signal> [context...]
; http.post <url> <body> <signal> [context...]
http.post https://libretranslate.example/translate q=$urlencode($text)&source=auto&target=en done $chan

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

Field/functionValue
$httpoktrue if the request succeeded
$httpstatusHTTP status code
$httpbodythe raw response body
$json(body, key)a top-level field from a JSON body
on SIGNAL:done {
  if ($httpok == true) {
    echo $1 ↳ $json($httpbody, translatedText)   ; $1 = the buffer we passed as context
  }
}
Use timer for anything repetitive or heavy. A handler runs on the UI thread, 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.

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 that 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).

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 %sides $1
  if ($len(%sides) == 0) { set %sides 6 }       ; default d6
  set %r $calc($int($calc($rand * %sides)) + 1)  ; if your build lacks $rand, see note below
  msg $chan rolls a %sides-sided die: %r
}

No built-in RNG? Derive one from age.rand or seed from $calc on a changing value, e.g. hash the time with age.sha and take $mod of the result.

2 · Keyword highlighter

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

on LOAD { set %watch deploy }
on TEXT {
  if ($isme == false && $contains($lower($text), %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 %lang $setting(applang)
  if ($len(%lang) == 0) { set %lang en }
  set %ep https://libretranslate.example/translate   ; your endpoint
  set %key                                            ; API key (blank = none)
}

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

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

4 · A scoreboard with a view

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

on LOAD { set %score $map() }

alias point {
  if ($has(%score, $1) == false) { setat %score $1 0 }
  setat %score $1 $calc($get(%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(%score)) {
          row gap 8 { text %p weight 1 | text $get(%score, %p) bold color #58a6ff }
        }
        button "Clear" clear fill
      }
    }
  }
}

on SIGNAL:clear { set %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.