Mastering UserTalk
The language behind Frontier: verbs, tables, threads and semicolons.
UserTalk is not a large language. A competent programmer can read its entire grammar in an afternoon and still be discovering, three years later, that half of what makes it productive is not in the grammar at all — it is in the way the language and the object database refuse to be separate things.
This article is the guide we wish had existed when we started: what the syntax actually means, where the sharp edges are, how verbs and tables really work, and which habits separate code that survives a decade from code that has to be rewritten by the next person.
The one idea you need first
In most languages, your program is text in a file and your data lives somewhere else — a database, a config file, memory. In Frontier, the program and the data are the same structure. A script is a node in a hierarchical database. So is a string, a table, a window position, a user preference, and the outline you are editing. Everything has an address, everything persists across restarts, and nothing is loaded or saved by you.
Once that lands, a great deal of UserTalk stops being arbitrary. There is no import statement because there is nothing to import: you name the address. There is no serialisation layer because nothing is ever not serialised. A global is just a node you happened to reference.
The mental substitution
Whenever you would reach for a file, a config parser or a cache in another language, ask instead: where in the database does this live? The answer is usually a table you create once and then simply address.
Syntax you will actually type
UserTalk is semicolon-and-brace shaped, but the outliner supplies both. In the script editor you write one statement per line and the structure comes from indentation; the braces and semicolons you see in flattened listings are what the language produces when the outline is rendered as text.
on wordFrequency (adr) {
«Return a table of word -> count for the text at adr.
local (text = string (adr^));
local (words, w, result);
if sizeOf (text) == 0 {
return (nil)};
new (tableType, @scratchpad.freq);
words = string.multipleReplaceAll ({",": " ", ".": " "}, text);
for w in string.nthField (words, ' ', infinity) {
if sizeOf (w) > 0 {
if not defined (@scratchpad.freq.[w]) {
scratchpad.freq.[w] = 0};
scratchpad.freq.[w]++}};
return (@scratchpad.freq)}
Four things in that snippet carry most of the language's character:
«begins a comment and runs to the end of the line. It is the guillemet, not a double angle bracket, and it is why pasted UserTalk from a web page so often fails to compile.@means the address of, and^means the value at. They are the whole pointer story.@scratchpad.freqis an address;adr^is whatever is sitting at the address you were handed..[expression]indexes a table by a computed name, which is how you use the object database as a dictionary without inventing a dictionary type.local (x = value)declares and initialises in one move. Anything you do not declare local is a database node, which is convenient exactly once and then costs you a weekend.
Types, and the fact that they are honest
UserTalk is dynamically but strongly typed, and the type names are the ones you would guess:
stringType, numberType, booleanType, dateType,
tableType, addressType, listType, recordType,
binaryType, outlineType, scriptType. Coercion happens on demand and
mostly does what you want, but it will raise rather than guess when the answer is ambiguous.
The habit worth forming early is checking typeOf at the boundary of any handler that can
be called from somewhere you do not control — a web request, a message, a scheduled agent —
and converting once, at the top, rather than defensively everywhere.
on renderPage (adrPage, flPreview = false) {
if typeOf (adrPage) != addressType {
scriptError ("renderPage: expected an address, got " + typeOf (adrPage))};
if not defined (adrPage^) {
scriptError ("renderPage: nothing at " + string (adrPage))};
local (pagetable = adrPage^);
if typeOf (pagetable) != tableType {
pagetable = {"body": string (pagetable)}};
return (template.process (pagetable, flPreview))}
Verbs, tables and the shape of a suite
A verb is a script you can call. A suite is a table of them, plus whatever data they need, living under one address. That is the entire module system, and its simplicity is the reason twenty-year-old suites still drop into a modern database and work.
The convention that makes a suite pleasant to use by someone who did not write it:
- One table at the root, named for the suite, with a short lowercase name.
- A
datasub-table for persistent state, so it is obvious what survives a restart. - A
prefssub-table with defaults, written once at install time, never at call time. - Public verbs at the top level of the suite table, with names that read as commands:
publish,rebuild,validate. - Private helpers under a
privtable. The language will not stop anyone calling them; the convention tells the honest reader not to. - An
initverb that is idempotent — safe to call twice, because it will be.
on init () {
«Safe to run repeatedly; creates only what is missing.
local (adr = @user.myTool);
if not defined (adr^) {
new (tableType, adr)};
if not defined (adr^.data) {
new (tableType, @adr^.data)};
if not defined (adr^.prefs) {
new (tableType, @adr^.prefs)};
«Defaults are only written when absent, never overwritten.
local (defaults = {"retries": 3, "timeout": 30, "verbose": false});
local (key);
for key in defaults {
if not defined (adr^.prefs.[key]) {
adr^.prefs.[key] = defaults [key]}};
return (true)}
Parameters, defaults and the calling convention
Parameters can carry defaults in the declaration, which removes most of the argument-counting logic
other languages of the era needed. Parameters are passed by value except addresses, which are passed as
addresses — so a handler that takes adrData and writes through it is mutating the
caller's data, deliberately and visibly. Name such parameters adrSomething without exception;
it is the single most useful naming convention in the language.
Threads, agents and the illusion of concurrency
Frontier is cooperatively multi-threaded. A script yields when it calls something that waits —
network, disk, an explicit thread.sleep — and otherwise it holds the runtime. This is
liberating and dangerous in the same breath: you get concurrency without locks for most real work, and
a single tight loop without a yield will freeze the entire environment, including the editor you would
use to stop it.
on rebuildAll (adrSite) {
local (i = 0, adrPage);
for adrPage in site.pageList (adrSite) {
renderPage (adrPage);
i++;
«Yield every 25 pages so the UI and the web server keep breathing.
if i mod 25 == 0 {
msg ("Rebuilt " + i + " pages…");
thread.sleepFor (0.01)}};
msg ("");
return (i)}
Agents are scheduled scripts: a handler plus a firing interval, stored in the database like everything else. They are the right home for anything periodic, and the wrong home for anything that must run exactly once at a precise moment, because an agent that overruns its interval will happily start again underneath itself. Guard with a flag in the database, and clear the flag in an error handler, not only on the success path.
Errors, and the try that actually helps
UserTalk's try catches a script error and gives you the message. What it does not do is
tell you where, unless you arrange it. The pattern that has repaid itself most often in our work is to
wrap at the boundary, log with context, and re-raise only what the caller can do something about.
on safePublish (adrPage) {
local (started = clock.now ());
try {
publish (adrPage);
log.add ("publish", string (adrPage) + " ok in " +
string (clock.now () - started) + "s")}
else {
«tryError holds the message; add what the runtime cannot know.
log.add ("publish-error", string (adrPage) + ": " + tryError);
mail.sendToOps ("Publish failed", string (adrPage) + cr + tryError);
return (false)};
return (true)}
The rule we enforce in audits
Never write a bare try with an empty else. A swallowed error in a
persistent, always-on runtime does not disappear — it comes back in six weeks as data
corruption nobody can explain.
Ten habits that separate durable code from disposable code
- Declare every local. An undeclared name silently becomes a database node and you will find it, populated, in production, in 2031.
- Prefix address parameters with
adr. It tells the reader that writing through it is visible to the caller. - Build strings with in-place append. See the dedicated article; it is the single largest avoidable cost in most suites.
- Yield inside any loop that can exceed a second. Your future self, unable to reach the editor, will thank you.
- Make
initidempotent. Installers get run twice. Always. - Never write defaults at call time. Writing preferences on every call turns a read-mostly database into a write-heavy one and destroys backup deltas.
- Keep verbs under thirty lines. The outliner makes long scripts look short. They are not short.
- Log a correlation value. A timestamp plus the address involved turns an unreproducible bug into a five-minute grep.
- Store dates as
dateType, not strings. Comparison and arithmetic are free; parsing back from a formatted string is not. - Write the runbook in the database. A
readmenode inside the suite table travels with the code and cannot be lost in a wiki migration.
What to read next
If the object-database model is the part that still feels strange, go to Inside the Object Database next — the addressing rules make far more sense once you have seen how the root table is actually laid out. If your immediate problem is that something is slow, Optimal String Concatenation will very likely find it.
Take this away
- Program and data share one persistent structure — address it, do not load it
@is address-of,^is value-at,«starts a comment- Declare every local, or it becomes a database node forever
- Yield inside long loops; the runtime is cooperatively threaded
- Make installers idempotent and never swallow an error