Skip to content
Project Hollowâ„¢Docs
Hollow AIHollowScriptAppsPlatformDocs
GuidesAPILanguage
Start hereOverview
HollowScriptOverviewInstall and runTypesSyntax overviewFunctions and closuresRecords and optionalsControl flowStandard libraryCapabilitiesModules
HELOverviewSyntaxOperators and valuesDates, ranges and sentinels

HollowScript

HollowScript

HollowScript

HollowScript is a statically typed, statement-oriented programming language with full type inference, closures, generics, an immutable data model, and a capability system that keeps every interaction with the outside world declared and visible. Scripts are plain text files saved with a .hws extension.

HollowScript powers automation inside Hollow Cascade and is also available as a standalone tool. The same language runs in both: write a script once and run it from the command line, embed it in a Cascade step, or drive it from any TypeScript or JavaScript host via the published library.

On this pageInstall and runTypesSyntax overviewFunctionsRecordsControl flowStandard libraryCapabilitiesModules

Install and run

HollowScript is available as a standalone binary that requires no Node.js installation, and as a library for embedding in a TypeScript or JavaScript application.

Command line

One command installs the standalone binary and verifies its checksum before writing anything to disk.

# macOS and Linux
curl -fsSL https://storage.googleapis.com/sstk-hollow-hollowscript-releases/install.sh | sh

# Windows
irm https://storage.googleapis.com/sstk-hollow-hollowscript-releases/install.ps1 | iex

Do not download those binaries in a browser on macOS: they are not yet code-signed, and macOS quarantines browser-downloaded binaries on sight. The curl install path does not trigger quarantine.

hollowscript --version
hollowscript run hello.hws
hollowscript check hello.hws
hollowscript format hello.hws
hollowscript repl
run
Parse, type-check and execute a script file. Exits 0 on success, 1 if compilation failed, 2 if execution failed, and 3 for a malformed command line.
check
Parse and type-check without executing. Reports all diagnostics and exits non-zero on any error.
format
Reformat a source file in the canonical style. A file with parse errors is not formatted; diagnostics are reported instead.
repl
Start an interactive read-evaluate-print loop. Useful for exploring the language and testing expressions.

The CLI grants print, clock and random by default and nothing else. Running a script someone gave you cannot touch your filesystem or the network, regardless of what its uses line requests.

Types

HollowScript is statically typed with inference: types are checked at compile time and most of them are worked out automatically, so you only need to write a type annotation where the language cannot determine it itself.

Int
Arbitrary-precision integer. Literal: 42, -3.
Float
64-bit floating-point number. Literal: 3.14. Division between two Ints always produces a Float.
Bool
Boolean. Literals: true, false.
String
UTF-8 text. String literals use double quotes: "hello". Interpolation with ${expression}. Length and slicing count Unicode code points, not UTF-16 code units.
Filter
A compiled HEL filter expression, written between backticks: `status:open priority:high`. Passed to capability calls that accept a filter; opaque to pure HollowScript computation.
[T]
A list of elements of type T. All values are immutable; list operations return new lists. Index access returns T?: an out-of-range index yields none rather than an error.
{ field: T, … }
A record. Records are structural: a value fits a record type when it has exactly that field set with compatible types. Declared with the record keyword or written inline.
Result<T>
A built-in record equivalent to { value: T?, error: String? }, with exactly one field set at runtime. Produced by success(value) and failure(message).
T?
An optional: the type T or none. Optionals do not nest: T?? is a compile error.
fn(T): U
A function type. Closures are first-class values and may be stored in variables or passed to higher-order functions.

Type annotations are required on function parameters, on a function's return type when it returns a value, and on declarations whose initial value gives no type information (an empty list literal, or the literal none). Everywhere else, the type is inferred.

Arithmetic operators + - * stay Int when both sides are, and produce Float the moment either side is. Division / always produces Float. + never concatenates strings; use interpolation or join on a list of strings instead.

Syntax overview

A complete HollowScript program, annotated against a real example.

// uses declares every capability this script may reach
uses tasks, print

// constant binds a name once; variable binds a name that can be reassigned
constant filter: Filter = `status:open priority:high`
constant urgent = effect tasks.where(filter)

for task in urgent {
  match task.owner {
    none { effect print("Unassigned: ${task.title}") }
    else { effect print("Urgent: ${task.title}, owned by ${task.owner}") }
  }
}

return urgent.length()
uses
Declares every capability the script may reach. Nothing outside this list is reachable. Must appear at the top of the file.
constant
Binds a name to a value permanently. A variable binding can be reassigned with =. Neither makes the value itself mutable: all values are immutable.
effect
Required prefix on every call that reaches a capability. A capability call without effect, or an effect on an ordinary function call, is a compile error. Every effect you see is a verified claim.
`…`
A backtick literal is a HEL filter expression, producing a value of type Filter. .where() is the one place HollowScript and HEL meet.
for … in
Iterates every element of a list.
match … { }
Compares a value against literal alternatives. Every match requires a trailing else branch; the compiler rejects one that omits it.
return
Ends the script (or a function body) and hands its value back to the caller. A return at the top level of a file hands the value to the host.
// …
Single-line comment.

Functions and closures

Named functions are declared with fn. Parameter types and the return type (when there is one) are always written explicitly.

fn add(a: Int, b: Int): Int {
    return a + b
}

return add(2, 3)

A function that declares a return type must return a value on every path through it. A function that declares no return type must never return a value; it is called for its effects.

Closures are unnamed functions used as values. They have the same syntax as a named function, minus the name, and they capture the values in their enclosing scope at the time they run:

constant makeAdder = fn(n: Int): fn(Int): Int {
    return fn(x: Int): Int {
        return x + n
    }
}

constant addFive = makeAdder(5)
return addFive(10)

Generics use angle-bracket syntax on function declarations. Type parameters are inferred at the call site and never written explicitly by the caller:

fn identity<T>(value: T): T {
    return value
}

return identity("hello")

Recursion is supported. The runtime enforces a call-depth limit to prevent runaway recursion from overflowing the host's own call stack; exceeding it stops the script with a reported failure rather than a silent crash.

Records and optionals

A record is a fixed set of named fields. Records are structural: compatibility is based on field names and types, not on what name a type was declared with.

record Point = { x: Int, y: Int }

constant origin: Point = { x: 0, y: 0 }
constant moved: Point = { x: origin.x + 1, y: origin.y }
return "${moved.x}, ${moved.y}"

There is no constructor call and no class. A record literal is just { field: value, … }, checked against the type it is used as. A missing field or an unknown field is a compile error.

A type written as T? may also hold none. Optionals do not nest.

record Task = { title: String, owner: String? }

fn describe(task: Task): String {
    if task.owner != none {
        return "${task.title}, owned by ${task.owner}"
    }
    return "${task.title}, unowned"
}

return describe({ title: "Ship it", owner: "Lee" })

The check task.owner != none does more than compare: inside that branch, task.owner is narrowed from String? to String, so it can be used directly with no further check. The same narrowing applies after a guard clause that returns early, and in match branches with a none arm.

Recursive records are fully supported: a record may contain an optional field of its own type.

Control flow

if … else if … else
Standard conditional. Braces are required; else is optional.
match … { }
Matches one value against literal branches. Branches may list more than one value, comma-separated. The mandatory else handles anything not covered by an explicit branch. A none branch on an optional scrutinee narrows every other branch to the non-optional type.
for … in
Iterates every element of a list, in order.
while
Repeats a block while a condition is true.
break
Exits the nearest enclosing for or while loop immediately.
continue
Skips the rest of the current loop iteration and advances to the next.
return
Returns a value from the current function or, at top level, from the script itself. Accepted with no value in a function with no return type.
fn describe(n: Int): String {
    match n {
        0          { return "zero" }
        1, -1      { return "one, either sign" }
        else       { return "something else" }
    }
}

variable total = 0
variable i = 0
while i < 5 {
    if i == 3 { break }
    total = total + i
    i = i + 1
}

return "${describe(1)}, total ${total}"

Unused names declared with constant or variable produce a warning. Shadowing a name already in scope is a compile error anywhere in the file.

Standard library

Five pure modules ship with the language itself. None of them touches anything outside the script, so none of them need a uses declaration. They are always available.

Strings

length(): Int
contains(part: String): Bool
startsWith(prefix: String): Bool
endsWith(suffix: String): Bool
toUpper(): String
toLower(): String
trim(): String
split(separator: String): [String]
replace(from: String, to: String): String  // every occurrence
substring(start: Int, upTo: Int): String   // start inclusive, upTo exclusive, clamped

Numbers

abs, min and max preserve Int when every argument is an Int, and otherwise return Float.

round(x: Float): Int
floor(x: Float): Int
ceil(x: Float): Int
sqrt(x: Float): Float
pow(base: Float, exponent: Float): Float
abs(x): same type as x
min(a, b): Int if both Int, otherwise Float
max(a, b): Int if both Int, otherwise Float
remainder(a: Int, b: Int): Int
range(from: Int, upTo: Int): [Int]  // empty when from >= upTo

Lists

sort() requires elements of type Int, Float, or String; use sort(compare:) for any other element type. The comparator follows the usual convention: negative when its first argument sorts before its second, zero when equal, positive when after. join requires [String].

length(): Int
isEmpty(): Bool
contains(item: T): Bool
first(): T?
last(): T?
append(item: T): [T]        // returns a new list
concat(other: [T]): [T]
map<U>(transform: fn(T): U): [U]
filter(keep: fn(T): Bool): [T]
each(action: fn(T))
reduce<A>(initial: A, combine: fn(A, T): A): A
sort(): [T]                  // T must be Int, Float or String
sort(compare: fn(T, T): Int): [T]
reverse(): [T]
join(separator: String): String  // T must be String

Records

keys(r): [String]
has(r, name: String): Bool

Results

success<T>(value: T): Result<T>
failure<T>(message: String): Result<T>

Capabilities

HollowScript has no built-in way to print, read the clock, generate a random number, read a file, or make a network call. Every interaction with the world beyond a script's own computation is a capability: something whoever runs the script grants explicitly, before it starts.

  • Declared, not ambient. The uses line at the top of a file is the complete capability surface. Nothing outside it is reachable, and nothing needs a runtime permission check to prove it.
  • Marked at every call. The effect keyword is required on every call that reaches a capability, whether it reads or writes. A capability call without effect, or an effect on an ordinary function call, is a compile error. Every effect in a script is a verified claim.
  • Fails loudly. A panic is caught at the script boundary and reported as a failure with a diagnostic, never a silent no-op.

The three standard capabilities

Any host that provides these provides them with these exact shapes, so a script written against one host reads the same against another.

print(text: String)
Writes a line of text to the output. In a Cascade step, every print call is captured into that step's run log.
clock.now(): String
Returns the current instant as an ISO-8601 string, for example 2026-07-31T09:00:00Z.
clock.today(): String
Returns the current calendar date as a YYYY-MM-DD string.
random.int(min: Int, upTo: Int): Int
Returns a random integer, min inclusive and upTo exclusive.
random.float(): Float
Returns a random float in the range [0.0, 1.0).
uses print, clock, random

effect print("the time is ${effect clock.now()}")
constant roll = effect random.int(1, 7)
return roll

Cascade capabilities

Hollow Cascade grants two additional capabilities beyond the three standard ones.

input(stepId: String, key: String): String
Reads a named output field from an earlier step in the same cascade. Both arguments are strings: the step's ID and the key within its output.
tasks.where(filter: Filter)
Queries your real Tasks through a HEL filter and returns a list of task records. Each record has the fields id, title, status (all String), and optional priority and dueDate (both String?). Read-only in the current release.
uses print, clock, tasks

constant urgent = effect tasks.where(`priority:high status:todo`)
effect print("${urgent.length()} urgent tasks at ${effect clock.today()}")

See HollowScript running inside Cascade →

Modules

A file can import names from another file by relative path, without the .hws extension. Imported names bind directly, with no renaming or wildcards.

// main.hws
import double from "./double"

return double(21)

// double.hws
export fn double(x: Int): Int {
    return x * 2
}

The keywords export and import apply to fn, record, and constant declarations. Top-level statements and variable declarations cannot be exported.

uses lines are not inherited through an import. Each file states its own capabilities, so reading the top of one file always tells you everything it can touch outside its own computation, without needing to trace its imports.

Project Hollow

Hollow AIAppsPlatform

Documentation

OverviewGetting startedHollow StudioApp reference

Account

Sign inCreate accountEarly access

Legal

PrivacyTermsContact
© 2026 Project Hollow