Reference

Language Reference

Syntax and behavior for v1.0.0-rc9. Every code sample is checked against the same interpreter used by the runnable examples.

Vibescript lets users and AI agents add scripts to a Go app. Like Lua in a game, each script runs inside limits set by the host app. This page covers the language, task system, sandbox, and main host settings. For the full Go API, capability adapters, and built-in methods, see the upstream docs.

Basics

Source structure

Vibescript files are UTF-8 text and usually use a .vibe extension. A # starts a comment that continues to the end of the line.

A file can declare functions, classes, modules, and enums. Statements at the top level become the default script body when the file runs directly. When require loads the file, those statements become the module initializer. An app that embeds Vibescript will usually call a named function instead. The runnable examples on this site work that way.

Use newlines or semicolons to separate statements. An expression can also be a statement.

Values & literals

missing = nil
active = true
count = 42
price = 19.99
label = :active
greeting = "hello #{name}"
tags = ["alpha", "beta"]
config = {retries: 3, verbose: true}
window = 1..5
grace = 2.days

The literal categories are nil, true, false; integers and floats; strings; symbols (:name, or quoted as :"with-punctuation"); arrays; hashes; ranges; and duration literals such as 5.minutes or 2.days.

.. includes the final value, while ... leaves it out. Open-ended ranges work too: arr[1..] takes everything from index 1 on, s[..2] takes the first characters, and when 3.. matches three and up.

You cannot iterate an open range. each, map, to_a, for, and similar operations return an error before they run. Unlike Ruby, a descending range does iterate: (5..1).to_a returns [5, 4, 3, 2, 1] instead of an empty array.

Numbers

Vibescript has one integer type: int. It keeps exact values when arithmetic goes past the signed 64-bit range, and uses the smaller representation again when the value fits. Scripts never see a separate "bignum" type.

Some APIs still require a value that fits in 64 bits and return an error for a larger value. These include range endpoints, iteration counts (times, upto, downto, step), Money/Duration/Time arithmetic, and arguments used as indexes, counts, sizes, or precision values.

big = 2 ** 100          # => 1267650600228229401496703205376, exact
readable = 1_000_000    # underscores are visual separators
hex = 0xDEAD_BEEF       # 0x, 0b, 0o, and 0d base prefixes
sci = 1.5e-2            # floats may use scientific notation
kilo = 1e3              # any literal with an exponent is a float: 1000.0

Numeric literals can use underscores between digits in any base. An exponent (e or E) can include a sign and must include at least one digit. An exponent that is too large for a 64-bit float becomes Infinity.

A number cannot touch an identifier. 123abc and 1.5x are parse errors, not two tokens. A leading zero does not make a number octal, so 010 is decimal.

Strings & symbols

Double-quoted strings support #{...} interpolation. Vibescript evaluates the expression and converts its value with to_s. The expression can contain its own double-quoted strings and nested interpolation, and ends at the matching }. Escape a literal marker as \#{...}. Single-quoted strings do not interpolate.

def describe(name)
  "#{name || "guest"} checked in"
end

Symbols are usually written bare (:name), but a quoted form lets a symbol hold punctuation, spaces, or be empty: :"foo-bar", :'foo bar', :"". Quoted symbols use the same escapes as the matching string quote, and interpolation is not supported in symbol literals.

In a hash, a bare label creates a symbol key and a quoted label creates a string key. Read { name: 1 } with h[:name], and read { "name": 1 } with h["name"]. A quoted label is the only hash literal syntax for a string key. JSON.parse returns hashes with string keys. Ruby's hash rocket syntax (=>) is not supported.

Strings are immutable values. Reading with [] mirrors Ruby's String#[] and Array#[], including negative indexes, value[start, length], and value[range] slices.

Variables & assignment

Assignment creates variables. Parallel and destructuring assignment split an array across several targets:

a, b = [1, 2]
first, *middle, last = [1, 2, 3, 4]
x, (y, z) = [1, [2, 3]]

Missing values bind as nil, extra values are ignored unless captured by a *rest target, and scalar right-hand values are treated as one value. A bare * is an anonymous rest target that discards what it captures. It can sit at the front, middle, or end (*, last = [1, 2, 3]).

Index assignment works on mutable collections, and array targets accept a negative index that counts back from the end:

items = [1, 2, 3]
items[0] = 10
items[-1] = 30

Compound assignment is supported for variables, member targets, and index targets with +=, -=, *=, /=, %=, and **=:

total += amount
items[0] *= 2
record[:score] **= 2

Functions

Defining functions

Define functions with def/end. The last evaluated expression is the return value, and return exits early:

def add(a, b)
  a + b
end

Type annotations on parameters and return values are optional. When present, Vibescript checks them at runtime when the function is called and returns:

def charge(amount: int, currency: string = "USD") -> hash
  {amount: amount, currency: currency}
end

Parameter forms

A parameter's syntax controls how it receives a value. The token after : separates keyword parameters from typed parameters:

Form Meaning
name required positional parameter
name = default optional positional parameter
name: Type typed positional parameter
name: Type = default typed positional parameter with a default
name: required keyword-only parameter
name: default optional keyword-only parameter
*rest captures extra positional arguments
**rest captures extra keyword arguments
&block captures a passed block

A keyword-only parameter only accepts its matching label, not a positional argument. An optional keyword uses its default when the label is missing. A later default can use an earlier parameter:

def connect(host:, port: 8080, scheme: "https", timeout: port * 2)
  "#{scheme}://#{host}:#{port}"
end

def demo
  connect(host: "example.com")            # port 8080, scheme "https"
  connect(host: "example.com", port: 443) # overrides port
end

name: Type declares a typed positional parameter, so a bare name after : is read as a type. Write a: int for a typed positional parameter and a: 0 for an optional keyword. If a default is only a reference to an earlier parameter, put it in parentheses. timeout: port * 2 is a default, but timeout: port looks like a type. Write timeout: (port) instead.

Function values

Refer to a function by name without calling it to get a function value. You can pass that value around and call it later. fn(...) and fn.call(...) behave the same way. Both accept positional arguments, keyword arguments, and an optional block:

def inc(n)
  n + 1
end

def apply_twice(fn, value)
  fn.call(fn(value))
end

def demo
  apply_twice(inc, 40) # => 42
end

The only member exposed on a function value is call. A zero-arity function is auto-invoked when referenced by name, so it cannot currently be passed as a function value.

Calls & Blocks

Method calls

Calls accept positional and keyword arguments. You can leave out parentheses when all arguments are on one line:

def demo(fees, amount)
  fees.apply(amount)
  fees.apply amount
  render status: "ok"
end

Positional arguments must come before keyword labels: collect(first: 1, "tail") is a parse error, while collect("head", first: 1) is accepted.

A label becomes a keyword argument when the function accepts that keyword. If the function expects a positional hash instead, the labels become its final options hash. This works with or without parentheses:

def configure(opts)
  opts[:retries]
end

def demo
  configure(retries: 3)  # => 3
  configure retries: 3   # same call
end

Constructors (Klass.new(...)) and methods (receiver.method(...)) use stricter rules. Inside parentheses, a keyword without a matching parameter does not become a positional options hash.

A local variable already holding the value of a keyword can be passed with the shorthand greet(name:), which is greet(name: name).

Splats & parenless arguments

Ruby-style splats expand saved arguments. f(*args) turns an array into positional arguments. f(**opts) turns a hash into keyword arguments; keys can be strings or symbols, and the last duplicate wins. You can combine both forms with regular arguments and blocks:

def sum3(a, b, c)
  a + b + c
end

def demo
  args = [2, 3]
  sum3(1, *args) # => 6
end

Vibescript expands splats before it binds parameters. Errors about argument count, keywords, and types are therefore the same as they would be for a call written out in full.

Spacing decides how a call without parentheses is read. f *args uses a splat, while a * b and a*b multiply. The same rule allows a regex or array literal as an argument (match /ID-[0-9]+/, puts [3, 1, 2].sort).

In a call to a non-local function, an operator-like symbol starts an argument when it is separated from the function name but touches its value. With any other spacing, Vibescript reads it as an operator.

Blocks

Blocks are small functions passed with do ... end or braces. Missing block arguments become nil. Block parameters can also unpack a yielded value in the same way as destructuring assignment:

def active_names(players)
  players
    .select do |player|
      player[:active]
    end
    .map do |player|
      player[:name]
    end
end

def firsts(rows)
  rows.map do |(head, *)|
    head
  end
end

A function runs its caller's block with yield, and block_given? reports whether the current call was given one:

def fetch(default)
  if block_given?
    yield
  else
    default
  end
end

def demo
  fetch("none")             # => "none"
  fetch("none") { "value" } # => "value"
end

As in Ruby, return inside a block returns from the method that created the block and stops iteration. Any ensure block still runs. A block with no parameter list can use the implicit parameters it and _1 through _9.

Procs & lambdas

Use Proc.new { ... }, proc { ... }, lambda { ... }, or ->(args) { ... } to store a block in a value. Call each form with .call:

def demo
  double = ->(n) { n * 2 }
  add = lambda do |a, b|
    a + b
  end
  add.call(double.call(20), 2) # => 42
end

Procs and lambdas follow Ruby's rules. A proc acts like a block: missing arguments become nil, extra arguments are dropped, one array argument is expanded, and return exits the method that created the proc. A lambda acts like an anonymous method: it checks the argument count, and return, break, and next only leave the lambda. Use fn.lambda? to tell them apart.

& turns a value into the block for a call. m(&blk) forwards a saved block, proc, function value, or bound method. m(&:name) is the shorter symbol form:

def shout(words)
  words.map(&:upcase)  # => ["A", "B"] for ["a", "b"]
end

def total(numbers)
  numbers.reduce(&:+)  # => 6 for [1, 2, 3]
end

The & argument must be last, appears at most once, and cannot be combined with a literal block.

Safe navigation

receiver&.member reads a member or calls a method only when the receiver is not nil. If it is nil, the access returns nil without evaluating its arguments or block:

def demo(user)
  user&.name
  user&.profile("public")
  user&.profile&.name
end

The operator only guards the next access. In user&.profile.name, .name still runs on the value returned by user&.profile. Guard each link that may be nil. Safe navigation cannot be an assignment target, so user&.name = "Ada" is a parse error.

Operators

Operator families

  • Arithmetic: +, -, *, /, %, **
  • Comparison: ==, !=, <, <=, >, >=, <=>
  • Case equality: ===
  • Regex match: text =~ /re/ (character index of the first match, or nil) and text !~ /re/ (true when the pattern does not match)
  • Boolean: &&, ||, unary !
  • Collection: array << value (append), array & other (intersection)
  • Unary sign: prefix - negates a number; prefix + is the identity on numbers and strings
  • Conditional: condition ? when_true : when_false

The Ruby word forms and, or, and not are not boolean operators in Vibescript. They are ordinary identifiers, so they can be used as method names, function names, and hash labels. Use &&, ||, and !.

array << value appends in place and returns the receiver, exactly like Ruby's shovel. array & other returns a new array holding the elements common to both, duplicates removed, left order preserved. Following Ruby, + binds tighter than <<, which binds tighter than &.

Comparison & case equality

The spaceship operator <=> returns -1, 0, or 1 when it can order two values. It returns nil for values it cannot order, such as different kinds, money in different currencies, or a comparison with NaN. The operators <, <=, >, and >= raise an ArgumentError for the same values.

=== uses its left value as a matcher, just like case and when. A range checks membership, a regex tests a string, and every other value uses ==:

def demo
  (1..3) === 2       # => true
  /el+/ === "hello"  # => true
  1 === 1.0          # => false; int and float stay distinct kinds
end

Precedence & continuation

Operators follow the usual arithmetic and boolean order. ** groups from the right and binds more tightly than unary -, so -2 ** 2 means -(2 ** 2). An integer raised to a non-negative integer power stays an int, even past 64 bits. Mixed number types and negative integer exponents return a float.

Division follows Ruby: integer division by zero (1 / 0) raises, while float division by zero (1.0 / 0) follows IEEE 754 and yields Infinity, -Infinity, or NaN; inspect those with Float#nan?, Float#infinite?, and Float#finite?. && binds tighter than ||, and ternary conditionals sit below ||, associate to the right, and evaluate only the selected branch.

Vibescript reads a leading + or - differently from Ruby. When the operator touches its value, it starts a new statement. When a space follows the operator, it continues the previous line. This lets you indent multi-line arithmetic under its first value:

def demo(total, amount)
  total
    + amount
end

Control Flow

Conditionals

if / elsif / else and unless / else can be statements or return values. If no branch matches and there is no else, they return nil:

def label(score)
  if score >= 90
    "great"
  elsif score >= 80
    "passing"
  else
    "retry"
  end
end

Short statements can use modifier conditionals and ternaries:

def demo(active, suspended)
  status = "open" unless suspended
  active ? "active" : "inactive"
end

case / when

case returns the value of the matching branch. It returns nil if nothing matches and there is no else. Each when uses ===: ranges check membership, regexes test strings, and other values use equality. Use then for a one-line branch:

def label(score)
  case score
  when 100 then "perfect"
  when 90, 95 then "great"
  when 80..99 then "passing"
  else "ok"
  end
end

Targetless case evaluates each when expression as a predicate in order:

def bucket(score)
  case
  when score == 100 then "perfect"
  when score >= 80 then "passing"
  else "ok"
  end
end

Loops

while and until repeat while testing a condition. for ... in loops over arrays, ranges, and hashes. An optional do can separate the condition or collection from the loop body:

def countdown(n)
  out = []
  while n > 0 do
    out << n
    n -= 1
  end
  out
end

def sum_first_five
  total = 0
  for n in 1..5
    total += n
  end
  total
end

A for loop over a hash gets one [key, value] pair at a time in insertion order. break and next affect the closest active loop. They return an error outside a loop and cannot cross a function call. Short loops can also use the modifier form:

def demo(i)
  i += 1 while i < 3
  i -= 1 until i <= 0
  i
end

Every loop iteration uses part of the sandbox's step limit. An infinite loop therefore stops with an error instead of hanging the Go app.

Error handling

Use raise to report an error and begin / rescue / ensure to handle it. A rescue clause can save the error and read its message:

def safe_divide(a, b)
  begin
    a / b
  rescue RuntimeError => err
    "failed: #{err.message}"
  ensure
    cleanup
  end
end

Guard clauses

return works with modifier conditionals for early exits:

def ship(order)
  return "missing" unless order
  return "empty" if order[:items] == []
  "shipped"
end

Types & Structure

Classes

Classes hold state and methods. Create an instance with .new. Methods use the same parameter and return syntax as functions:

class Counter
  def bump(value: int) -> int
    value + 1
  end
end

def demo
  Counter.new.bump(1) # => 2
end

Inheritance is not supported. Instance variables (@name), class variables (@@count), accessors, mixins, and visibility are covered in the upstream classes guide.

Modules

Modules put functions and constants under one name. Use include to add a module's methods as instance methods, or extend to add them as class methods:

module Billing
  LIMIT = 5

  def self.code
    "ok"
  end
end

def demo
  Billing.code   # => "ok"
  Billing::LIMIT # => 5
end

module is only treated as a keyword when a constant name follows it. Modules can be nested (Outer::Inner), but they cannot be instantiated.

Load shared code from another file with require. A file module is separate from a module declared in source. The Go app controls where require can look with Config.ModulePaths and its allow and deny lists:

def demo(input)
  helpers = require("public/helpers", as: "helpers")
  helpers.normalize(input)
end

Enums

Enums define a fixed set of named values. Access a value with :::

enum Status
  Draft
  Published
end

def demo
  Status::Draft
end

Conversion, equality, and serialization behavior are covered in the upstream enums guide.

Gradual typing

Types are optional. Add them to parameters and return values where they help. Vibescript checks them when a typed function receives or returns a value. Before a script runs, the checker also tracks local types and reports known conflicts. Values without annotations stay dynamic.

Type names are case-insensitive: int, float, number, string, bool, nil, duration, time, money, array, hash/object, range, function, top-level enum names, and any. Append ? for nullable (string?, int?), join alternatives with | (int | string), and parameterize containers with array<T> and hash<K, V>.

Shape types list the fields in a hash. Add ? to an optional field name. Add ... at the end to allow other keys:

def apply_bonus(payload: { id: string, points: int }) -> { id: string, points: int }
  { id: payload[:id], points: payload[:points] + 5 }
end

def register(user: { name: string, age?: int, ... }) -> string
  user[:name]
end

Runtime

Built-ins

Vibescript includes assertions, conversion helpers, Time, Duration, and Money values, plus JSON and Regex helpers. The sandbox counts the work done by their operations. Create durations from integers:

def demo
  5.minutes
  2.days
end

The upstream built-ins guide and standard library guide list every method, including methods on strings, arrays, hashes, and ranges.

Tasks & concurrency

The Tasks API runs independent named functions at the same time. The runtime limits how many can run and keeps them inside a scope. This is structured concurrency: a task cannot outlive the Tasks.run or Tasks.map call that created it. Leaving the scope waits for every task. Errors appear through task.value or when the scope exits.

Tasks.map calls the same named function for each input. It returns results in input order, not completion order. Use max: to limit how many tasks run at once:

def score_user(user)
  user[:score] * user[:weight]
end

def score_users(users)
  Tasks.map(users, max: 2, with: :score_user)
end

Tasks.run gives you direct control over a scope. tasks.spawn(:function_name, arg, key: value) starts a named function and returns a handle. task.value waits for that task, then returns its result or raises its error. The block's value becomes the value of the scope:

def prepare_user(user)
  "prepared:" + user[:id]
end

def prepare_pair(first, second)
  Tasks.run(max: 2) do |tasks|
    left = tasks.spawn(:prepare_user, first)
    right = tasks.spawn(:prepare_user, second)

    [left.value, right.value]
  end
end

The scope waits automatically before it exits. Use tasks.wait only when code later in the same block must wait for the tasks started so far.

Each task gets its own execution state. It inherits the parent call's capabilities, globals, StrictEffects setting, and cancellation, but it does not share mutable local variables or block state.

Arguments, results, and inherited globals are copied between the parent and a task. They must contain data only. Functions, blocks, capabilities, and cyclic values cannot cross this boundary. A result held by a task handle counts against the parent's memory limit until the scope exits.

The Go app controls both task limits. DefaultTaskConcurrency applies when a script leaves out max:. MaxTaskConcurrency is the largest value a script may request. A larger request returns an error, such as Tasks.map max 99 exceeds host maximum 64.

Sandbox & quotas

Every run has three limits: steps, memory, and recursion depth. Loop iterations, including empty ones, use steps. Calls and allocations also count toward a limit. Arguments expanded from a splat cost the same as arguments written out in full.

When a script reaches a limit, it stops and returns a clear error: step quota exceeded, memory quota exceeded, or recursion depth exceeded. The Go app keeps running.

Recursion can never be unlimited because the interpreter uses the Go stack. Even the largest profile keeps a finite limit, so runaway recursion returns an error instead of crashing the process.

Scripts cannot access the filesystem, network, or clock on their own. The Go app can pass in data and typed capability adapters. These adapters check arguments and results. Values that cross this boundary must contain data only; functions and other callable values are rejected.

With StrictEffects enabled, globals must also contain data only. Every side effect must then go through a capability adapter. Cancelling the Go context also cancels the script and any tasks it started.

Every example on this site uses a small set of limits. The homepage shows the exact values.

Host configuration

Create an engine with vibes.Config. Its zero value gives you a working sandbox with safe limits:

engine, err := vibes.NewEngine(vibes.Config{
    StepQuota:              20_000,
    MemoryQuotaBytes:       256 << 10, // 256 KiB
    RecursionLimit:         32,
    StrictEffects:          true,
    DefaultTaskConcurrency: 4,
    MaxTaskConcurrency:     16,
    ModulePaths:            []string{"/srv/vibes/modules"},
})
Field Default Controls
StepQuota 1,000,000 steps per call; loops, calls, and allocations use it
MemoryQuotaBytes 16 MiB live interpreter memory per call
RecursionLimit 256 call depth; this limit is always finite
StrictEffects false whether globals must contain data only; side effects use capability adapters
ModulePaths none directories where require looks for modules
ModuleAllowList / ModuleDenyList none which modules may load
OutputWriter / ErrorWriter unset where puts/print/p and warn write; without a writer, they raise an error
RandomReader / RandomReadFunc crypto/rand source of random data for scripts
MaxSourceBytes 1 MiB largest source file one compile accepts
MaxCachedModules 1,000 largest number of compiled modules kept in the cache
DefaultTaskConcurrency 4 task limit when a script leaves out max:
MaxTaskConcurrency 64 largest max: value a script may request
DevMode false reload modules when their files change during development

A zero quota means "use the default." Set a quota to vibes.Unlimited to turn it off. You can also use a named profile instead of setting each quota. From smallest to largest:

Profile Steps Memory Recursion
low 1,000,000 16 MiB 256
medium 20,000,000 128 MiB 1,000
high 200,000,000 512 MiB 4,000
xhigh unlimited unlimited 10,000

vibes.ProfileHigh.ApplyTo(&cfg) changes only the three quota fields. vibes.QuotaProfileByName("medium") finds a profile by name. The vibes CLI uses the same profiles and defaults to xhigh because it runs your own scripts, not untrusted code. The upstream guides cover capability adapters, module rules, and per-call options (CallOptions.Globals, CallOptions.Capabilities): integration guide and host cookbook.