lashlang/language

The agent-facing execution language for the RLM protocol: program form, syntax, values, expressions, mutation, and control flow. Companion pages cover effects and the runtime path.

Language Reference

Lashlang is a compact CodeAct language for model-authored REPL cells. Syntax is brace-delimited (expressions inside { ... }, no significant whitespace), with familiar token-level cues for agents: if / else / for x in xs / while cond, and / or / not, and Python-shaped raw strings like r"""...""" or r'...'. The parser accepts the full module grammar, but the linked host surface decides which abilities and language features exist: receiver operations, reusable background process work, trigger registry operations, process sleep, process signals, and optional static process-map labels are enabled explicitly by the host and hidden from prompts when disabled. Declarative trigger syntax has been removed; programs construct host-provided trigger-source values and call triggers.register, triggers.list, or triggers.cancel. Timers and recurring jobs are host-owned source policies, not Lashlang syntax. Fail-fast unwrap uses Rust-style ?, the ternary is cond ? yes : no, and finish / print are foreground effect expressions.

Values
null, booleans, numbers, strings, lists, tuples, records, projected host descriptors, result wrappers, and immutable Image handles. Resource handles and process handles appear only when the linked host surface exposes those abilities. Lists and records are value-shaped and copy-on-write at mutation roots. Tuples are immutable sequence values and serialize to JSON arrays at host boundaries.
Program form
A program is a module with optional type and process declarations plus a foreground main block. Removed declarative trigger syntax is rejected by the parser with a diagnostic that points agents at the trigger registry. Disabled abilities remain syntax-valid where applicable but are rejected by the linker with a source-spanned lashlang feature `...` is disabled by this host diagnostic before runtime calls can be made. Simple and path assignment, receiver operations, print, if/else, for, while, break, continue, cancel, and finish are expression nodes; assignment and loops evaluate to null.
Expressions
Literals, variables, tuple comma expressions, builtin calls, receiver operations, named process starts, await, field/index access, unary operators, binary operators, ternaries, list comprehensions, result unwrap with ?, and Type { ... } literals.
Effects
All external work crosses ExecutionHost. Receiver operations return wrapper records when resource operations are enabled, background process starts return handles when process support is enabled, await resolves handles, and print creates model-visible observations. Linked modules are content-addressed ModuleArtifact records in the host/profile artifact store; durable Lashlang process input stores only module_ref, process_ref, host_requirements_ref, process_name, and JSON args. Nested process starts reload the artifact by ref and verify the required host surface before execution.

Host Surface

The authored language is stable, but the available modules and optional syntax are host-provided. Each turn links against a LashlangHostEnvironment: a LashlangHostCatalog, LashlangAbilities, and LashlangLanguageFeatures. The Lashlang runtime derives callable tool operations from the current ToolCatalog, then merges plugin-provided Lashlang resources such as value constructors and trigger source metadata. The prompt renderer uses that same effective surface, so the model sees only operations, constructors, lifecycle forms, and annotations that will link.

Tool operations
Callable tools become receiver operations through explicit LashlangToolBinding metadata. A tool declared with LashlangToolBinding::new(["board"], "play") appears to Lashlang as await board.play({ cell: 4 })?, while the host still dispatches the underlying tool name such as play_move. Tools without an explicit lashlang binding are ordinary model tools, not Lashlang module operations.
Module authorities
A module path resolves to a typed authority value such as Board, Web, or Agents. Named processes receive those authorities explicitly: process notify(mail: Gmail) { await mail.send({ body: body })? finish true }, then start notify(mail: gmail.work). This keeps process authority visible in the process signature instead of hidden in globals.
Value constructors
Plugins can add pure constructors with LashlangHostCatalog::add_value_constructor or add_trigger_source_constructor. Trigger-source constructors require a validated named object payload type such as cron.Tick, while the constructed source value such as cron.Schedule remains opaque. Constructors are ordinary expressions, not awaited effects: source = cron.Schedule({ expr: "0 8 * * *", tz: "UTC" }).
Ability gates
LashlangAbilities gates processes, sleep, process signals, and triggers. Disabled features are not shown to the model and fail during linking before an ExecutionHost operation can run.
Language features
LashlangLanguageFeatures gates optional static syntax. label_annotations enables @label(title: "...") and @label(title: "...", description: "...") for Lashlang execution graphs; it is disabled by default and enabled by RLM/workbench presets.
// Host/tool declaration.
ToolDefinition::raw("tool:play_move", "play_move", "...", input, output)
  .with_lashlang_binding(LashlangToolBinding::new(["board"], "play"));

// Lashlang authored by the model.
board = await board.read({})?
move = await board.play({ cell: 4 })?
finish { board: board, move: move }

Program Form

The RLM protocol extracts the first complete paired block whose start tag line trims to exactly <lashlang> and whose closing tag line trims to exactly </lashlang>. Text before the start tag is visible assistant prose; only text between the tag lines is Lashlang source. Inline text such as Use <lashlang> here remains prose and does not execute.

REPL state
Variables persist across <lashlang> blocks in the active RLM execution state. Read-only host bindings projected through RLM's projection API are already in scope (e.g., history) and should be used directly instead of reconstructed from prompt prose. Lazy app projections are ref-backed and resolved before each block runs.
Completion
finish ends the program and turn. When the host exposes a continuation operation, await control.continue_as({ task: ..., seed: { ... } })? is a receiver operation (not a keyword) that ends the current trajectory and activates a fresh-window AgentFrame in the same session. In schema-constrained runs, a bad final shape produces a runtime nudge asking the model to fix the value and finish again from another <lashlang> block.

The examples below assume the host has linked a lowercase workspace.default resource with the shown operations enabled.

<lashlang>
text = await workspace.default.read_file({ path: "Cargo.toml" })?
print { chars: len(text), head: slice(text, 0, 1200) }
</lashlang>

A typical AgentFrame switch packs the concrete state the AgentFrame needs and ends the cell. Computed expressions default to global state on the AgentFrame; values rooted at a projected binding (like input.prompt) stay projected, preserving their ProjectionRef when one exists.

<lashlang>
findings = []
for path in await workspace.default.glob({ pattern: "src/**/*.rs" })? {
  text = await workspace.default.read_file({ path: path })?
  if contains(text, "TODO") {
    findings = push(findings, { path: path, chars: len(text) })
  }
}
await control.continue_as({
  task: "summarize the TODO audit and propose a follow-up plan",
  seed: { problem: input.prompt, findings: findings }
})?
</lashlang>

Label Annotations

@label is optional static metadata for Lashlang execution graph visualization. It does not change execution semantics, resource dispatch, trigger routing, or process registry state. When label_annotations is disabled, annotated modules fail during linking with a clear disabled-feature diagnostic.

Syntax
@label(title: "Create inbox card") or @label(title: "Create inbox card", description: "Stores the button message"). title is required, description is optional, and both values must be string literals. Variables, interpolation, icons, colors, layout hints, and unknown keys are rejected.
Targets
At top level and inside process bodies, labels can annotate ordinary statements, awaited host/resource operations, assigned operation calls, start, sleep, wait_signal, signal_run, wake, yield, finish, fail, loops, and if branch nodes. Before a process declaration, the label still describes the process definition. Labels on declarations other than process and stacked labels are rejected.
Storage
Label metadata is part of the ModuleArtifact identity. Changing a label changes the module ref. Hosts see it through Lashlang execution graphs as execution-map node label_metadata; lifecycle node events continue to reference stable node_id values.

For the static-map versus dynamic-update model, including an example rendered graph, see Lashlang graph updates.

process on_button(inbox: Inbox, event: ui.button.Pressed) {
  @label(title: "Create inbox card", description: "Stores the button message")
  result = await inbox.cards.create({ title: event.button })?
  finish result
}

Syntax And Values

The language accepts newline-separated expressions. Outside comma-delimited syntactic lists, commas construct immutable tuples. Inside calls, lists, records, type fields, and process argument lists, commas keep their separator role.

Literals
Use null, true, false, numbers, strings, lists like [a, b], tuples like (a, b), (a,), and (), and records like { name: "lash", "with space": 1 }. Record insertion order is preserved for display and serialization.
Strings
"..." and '...' support \n, \r, \t, escaped matching quotes, and \\. """...""" and '''...''' are multiline with the same escapes. Raw strings such as r"...", r'...', r"""...""", and r'''...''' preserve content exactly for patches, JSON, scripts, Markdown, and heredocs.
Fields and indexes
value.field reads record and image fields. value[index] reads lists, tuples, and records; record indexes are string-coerced and missing keys read as null. List and tuple indexes support negative offsets for reads and slices. Tuple index assignment is rejected because tuples are immutable.
Images
Image values expose .id, .label, .size, .width, and .height. Image fields are immutable. len(image) is invalid; use image.size.
count = grep_results.count
files = grep_results.files_with_matches
print count, files
pair = count, files
finish { pair: pair, first: pair[0] }

Expressions

Expressions are deliberately small but cover the operations agents need for filtering, shaping, validation, and tool orchestration.

FormMeaning
a, b, (a,), ()Tuple construction. (a) remains grouping; foo(a, b) remains two arguments; foo((a, b)) passes one tuple argument.
a + b, a - b, a * b, a / b, a % bNumeric arithmetic. + also concatenates strings, lists, and tuples where supported by runtime value semantics. Mixed list/tuple concatenation is rejected.
==, !=, <, <=, >, >=Comparison operators used in conditionals and filters.
a and b, a or b, !a, not aBoolean composition and negation.
cond ? yes : noExpression ternary. Lashlang has no expression-form if.
name(args...)Builtin function call. User-defined functions are not part of the language surface.
[expr for x in xs if cond]List comprehension. Ordered for/if clauses build a new list; multiple for clauses nest left-to-right and each if filters the bindings to its left.
await RESOURCE.alias.operation({ ... })?, (await handle)?Fail-fast unwrap of a result wrapper: return .value on success or abort the block with the wrapper error.
results = await (
  workspace.default.read_file({ path: "left.txt" })?,
  workspace.default.read_file({ path: "right.txt" })?
)
finish { left: results[0], both: results }

Assignment And Mutation

Assignments are rooted at named variables. Path assignment mutates the variable's current value through copy-on-write root updates, not through shared-alias mutation. There is no aliasing of inner records or lists.

Supported targets
name = expr, record.field = value, record[key] = value, list[i] = value, and nested forms like state.groups[g].count = count + 1.
Rules
Record field/index assignment inserts or replaces fields. List assignment replaces an existing integer index only. Tuple indexes are read-only. Assigning through a missing nested record key or image field is an error.
counts = {}
for group in groups {
  current = counts[group]
  counts[group] = (current == null ? 0 : current) + 1
}
finish counts

Control Flow

Control flow is expression-backed but source-oriented. Loop cleanup is part of the runtime path, so loop escapes are explicit forms rather than arbitrary jumps.

if / else
Conditionals use blocks. else if parses as an else block containing another if.
for
for item in iterable { ... } iterates lists and tuples. Non-sequence iteration is a runtime error. range is the usual integer-loop helper.
while
while condition { ... } repeats while the condition is truthy. Prefer bounded loops where possible; break, continue, and finish keep their normal meanings inside the loop body.
break / continue
break exits the nearest loop through the iterator cleanup path. continue skips to the next iteration. They are rejected outside loops.
finish
finish value ends the whole program or turn. finish inside a loop is not a loop control form.

A short walkthrough that exercises iteration, else if chains, break/continue, and format. Loop variables are scoped to the loop body and the prior binding of label is restored on break.

nums = [1, 2, 3, 4, 5, 6]
seen = []
total = 0
for n in nums {
  if n == 2 {
    continue
  }
  if n > 4 {
    break
  }
  seen = push(seen, n)
  total = total + n
}
if total > 10 {
  label = "large"
} else if total > 5 {
  label = "medium"
} else {
  label = "small"
}
finish format("seen={} total={} label={}", join(seen, ","), total, label)

while is useful for bounded retry/fill loops when the limit is computed inside the program.

attempts = 0
items = []
while len(items) < 3 and attempts < 5 {
  attempts = attempts + 1
  candidate = format("item-{}", attempts)
  if contains(items, candidate) {
    continue
  }
  items = push(items, candidate)
}
finish { attempts: attempts, items: items }
read on ·