Projected Host Bindings
The host can inject named, read-only values into Lashlang scope without copying them into VM State. They behave like ordinary variables for read access but rejecting reassignment keeps the host-managed values authoritative.
Binding API
ProjectedBindings- Low-level map of names to
ProjectedValues the Lashlang executor receives at the start of each paired<lashlang>block. RLM builds it from publicRlmProjectedBindings, restored projection refs, and the livehistoryprojection. RlmProjectedBindings- RLM's public binding API.
bind_jsonandbind_valuestore inline values;bind_lazyaccepts a stableProjectionRef, not a raw host object. Duplicate names are errors.
Lazy projected values
ProjectionRef,ProjectionResolver,ProjectionRegistry- Stable lookup path for lazy host descriptors. A resolver turns a ref into an
Arc<dyn ProjectedHostDescriptor>immediately before execution. The default registry supports memory refs viaregister_memory; those refs are only valid while the registry instance lives, and missing refs fail clearly. ProjectedValue- Either a scalar
FlowValueor a customArc<dyn ProjectedHostDescriptor>for non-trivial shapes (lazy field access, length, indexing). Custom values may carry opaque projection-ref metadata so snapshots and seeds can rehydrate them without materializing the content. ProjectedHostDescriptor- Trait that lets the host serve
read_one/read_manyqueries on demand: each is aProjectedReadRequest(such asField,Index,Len,Render, orMaterialize) answered with aProjectedReadResponse, so large host-side structures need not be cloned into Lashlang values.
Reserved names
- Reserved names
historyand any host-projected globals are reserved. Attemptingname = ...over a projected name raises`name` is a read-only projected binding. The host-side seed channel (continue_as/spawn_agentseed:) can land projected entries that lashlang code then sees as read-only.
Snapshots & serialization
- Snapshots
- Lashlang snapshot JSON records projected globals as markers, optionally including
projection_ref. Restore creates unresolved projected placeholders; RLM resolves ref-backed placeholders through its resolver before the next paired<lashlang>block runs. - Operation arguments
- RLM serializes projected values with an internal
{"__projected__": ...}marker, then normalizes operation arguments through the tool manifest'sToolArgumentProjectionPolicy. Tool implementations should parse the normalized arguments as plain JSON. The default policy materializes projected values.continue_asandspawn_agentdeclare a seed-preserving policy, so only rootseed:entries remain projected; ref-backed values carry{"__projection_ref__": ...}so AgentFrames and child sessions re-resolve the same lazy source, while value-backed projections serialize as JSON.
Host-Provided Modules
Lashlang does not ship a global tool namespace. The Lashlang runtime builds the effective language surface from the current turn's RuntimeExecutionContext: it translates callable ToolCatalog entries into module operations and extends that catalog with plugin-provided Lashlang resources. Linking and prompt rendering both use this surface, so examples in the prompt are executable against the same host contract.
LashlangToolBinding- Tool manifests choose the authored call path.
LashlangToolBinding::new(["board"], "play")maps the tool toboard.play. Every callable catalog tool needs an explicit binding: a tool with no binding, or one whose module path is empty, is rejected when the tool catalog is built. Module path segments must be ASCII identifiers, and duplicate callable paths are rejected. LashlangHostCatalog- The catalog records module instances, resource authority types, receiver operations, pure constructors, named host data types, and trigger-source event metadata. Trigger and trigger payloads are validated named object types such as
cron.Tick; source values such ascron.Scheduleremain opaque host identities. PluginFactory::extension_contributions- Plugins contribute language resources that are not ordinary tools through this hook, which returns a
LashlangSurfaceContribution(abilities, language features, and aLashlangHostCatalog) under thelashlang.surfaceextension id: configured trigger-source constructors such ascron.Schedule(...), typed resource constructors, and other host descriptor constructors. Zero-config trigger sources such asui.button.pressed({})are added to the session catalog fromreg.triggers().declare(...). Session plugins can still register normal tools throughreg.tools(); those tools become module operations through their manifest metadata. LashlangAbilities- The host enables process starts, sleep, process signals, and trigger registry operations explicitly. A configured process registry supplies the generic process plane; the Lashlang runtime derives process/lifecycle availability from that plane for RLM sessions. Plugins such as the built-in session-trigger plugin contribute trigger operations and resources.
LashlangLanguageFeatures- The host enables optional static language features separately from executable abilities.
label_annotationsallows@label(...)process-map metadata; it is not an execution effect and does not grant any resource, process, trigger, or provider capability.
fn read_board_tool() -> ToolDefinition {
ToolDefinition::raw("tool:read_board", "read_board", "...", input, output)
.with_lashlang_binding(LashlangToolBinding::new(["board"], "read"))
}
fn extension_contributions(&self) -> Vec<PluginExtensionContribution> {
let mut resources = lashlang::LashlangHostCatalog::new();
resources.add_trigger_source_constructor(
["cron", "Schedule"],
schedule_config_type(),
lashlang::NamedDataType::object(
"cron.Tick",
vec![lashlang::TypeField { name: "fired_at".into(), ty: lashlang::TypeExpr::Str, optional: false }],
).expect("valid cron tick type"),
).expect("valid cron trigger source");
vec![PluginExtensionContribution::new(
LASHLANG_SURFACE_EXTENSION_ID,
LashlangSurfaceContribution::new(
LashlangAbilities::default().with_triggers(),
LashlangLanguageFeatures::default(),
resources,
),
).expect("valid lashlang surface")]
}
Resource Operations And Process Handles
Host work is explicit and typed at the boundary. Lashlang itself does not know provider APIs, file systems, shells, or subagents; it asks the registered ExecutionHost. Resource operations, named processes, process sleep/signals, and trigger registry operations are advertised only when the linked LashlangHostEnvironment enables them, and the RLM prompt is rendered from that same effective surface. In RLM, HostBridge dispatches ordinary module operations to the callable tool catalog, trigger registry operations to the runtime trigger store, process starts to the process service, and sleep through the runtime effect controller. Timers and recurring jobs are host-owned source policies, not Lashlang syntax.
Resource operations
await RESOURCE.alias.operation({ ... })- When the host exposes resource operations, receiver calls return
{ ok: true, value: ... }or{ ok: false, error: "..." }. Use?for ordinary happy-path operations and keep the wrapper for retries or expected failures.
Use ? for the happy path and inspect the wrapper when failures are expected. Mixing both styles in one block keeps fatal errors fail-fast while letting probes branch on outcome. The examples in this section assume the host linked a workspace.default resource and enabled process support.
// Fail-fast: the block aborts if the file can't be read.
manifest = await workspace.default.read_file({ path: "Cargo.toml" })?
lines = split(manifest, "\n")
// Inspect-the-wrapper: we expect this read might miss.
optional = await workspace.default.read_file({ path: "CHANGELOG.md" })
notes = optional.ok ? slice(optional.value, 0, 200) : format("no changelog: {}", optional.error)
// Iterate over a glob and skip files that fail to read.
items = []
for path in await workspace.default.glob({ pattern: "src/**/*.rs" })? {
hit = await workspace.default.read_file({ path: path })
if !hit.ok {
continue
}
items = push(items, { path: path, chars: len(hit.value) })
}
finish { line_count: len(lines), notes: notes, items: items }
Processes
start name(...)- When process support is enabled,
process name(...)declares a process definition andstart name(...)creates one process run. The returned handle addresses that run. processes.listawait processes.list({})?returns visible running runs. Optional filters includedefinitionandstatus, for exampleawait processes.list({ definition: daily_digest })?orawait processes.list({ status: "completed" })?.cancelcancel handleasks the host to stop a live process run. Cancellation is best-effort and uses the run handle identity.
Process definitions describe reusable background work; process runs are concrete invocations created by start or by matched trigger occurrences. Results come back as wrappers when you await. await over a list returns wrappers in order; over a record it returns wrappers keyed by name. The unwrap operator ? applies to (await h) just like to receiver operations. If label_annotations is enabled, @label titles and descriptions can appear in trace process maps without affecting execution.
Awaiting handles
awaitawait handleresolves one handle.await [h1, h2]resolves a list and returns wrapper records in order;await { a: h1, b: h2 }resolves a record of handles and returns a record of wrappers.(await handle)?unwraps the resolved result.
Subagents
A process can spawn a typed subagent with agent.spawn and finish its result; wrap that in a process and start several to fan work out in the background, then await a record of handles to join the results as wrappers.
process audit(agent: Agents, task: str) {
result = await agent.spawn({
task: task,
capability: "explore"
})?
finish result
}
// Kick off two long-running subagents in the background.
left = start audit(agent: agents.default, task: "audit module a")
right = start audit(agent: agents.default, task: "audit module b")
// Resolve both concurrently as a record of wrappers.
results = await { a: left, b: right }
if !results.a.ok {
finish { error: results.a.error }
}
finish {
a: results.a.value,
b: results.b.ok ? results.b.value : null
}
Ability gates
- Ability gates
- Disabled Lashlang abilities and language features are syntax-valid but link-invalid. The linker returns
lashlang feature `...` is disabled by this hostbefore execution, so a disabledprocess,start,sleep,wait_signal,signal_run, trigger registry operation, or@labelannotation cannot produce tool calls or observations.
Triggers
Lashlang owns the canonical trigger registry contract. A host exposes pure source constructors such as cron.Schedule(...); zero-config trigger sources such as ui.button.pressed({}) are exposed from the session's declared TriggerEvents. In both cases the catalog records protocol metadata saying the constructed value satisfies TriggerSource<Event>. Code then passes that source value, a process definition, and explicit inputs to awaited registry operations such as triggers.register, triggers.list, and triggers.cancel. Every process param is mapped exactly once; trigger.event is the direct whole-event placeholder inside inputs. A trigger registration is a durable rule; a matched runtime trigger occurrence starts the target process.
process daily_digest(tick: cron.Tick) {
wake { kind: "daily_digest_due", tick: tick }
finish true
}
source = cron.Schedule({
expr: "0 8 * * *",
tz: "UTC"
})
handle = await triggers.register({
source: source,
target: daily_digest,
inputs: { tick: trigger.event },
name: "daily_digest"
})?
registrations = await triggers.list({ target: daily_digest })?
broad_registrations = await triggers.list({})?
finish handle
A trigger source uses the same registry. The workbench declares a typed UI button trigger event; the runtime exposes ui.button.pressed({}) as a zero-config source constructor, and the selected button arrives in the matched occurrence payload:
process on_button(event: ui.button.Pressed) {
wake { kind: "button_pressed", button: event.button, message: event.message }
finish true
}
handle = await triggers.register({
source: ui.button.pressed({}),
target: on_button,
inputs: { event: trigger.event },
name: "button watcher"
})?
finish handle
The matching host contract exposes configured constructors, trigger-source relations, and typed trigger declarations. Source owners list source registrations by source type/key, keep their own source-specific schedules, and emit declared trigger occurrences that validate payloads and fan out to matching routes:
let cron_tick = NamedDataType::object(
"cron.Tick",
vec![TypeField { name: "fired_at".into(), ty: TypeExpr::Str, optional: false }],
)?;
let button_pressed = NamedDataType::object(
"ui.button.Pressed",
vec![
TypeField { name: "button".into(), ty: TypeExpr::Union(vec![TypeExpr::Enum(vec!["Red".into()]), TypeExpr::Enum(vec!["Blue".into()])]), optional: false },
TypeField { name: "message".into(), ty: TypeExpr::Str, optional: false },
TypeField { name: "pressed_at".into(), ty: TypeExpr::Str, optional: false },
],
)?;
resources.add_trigger_source_constructor(
["cron", "Schedule"],
schedule_config_type(),
cron_tick,
)?;
reg.triggers().declare(
TriggerEvent::new("Button", "ui.button", "pressed", button_pressed),
)?;
let cron_subscriptions = core
.triggers()
.subscriptions(TriggerSubscriptionFilter::for_source_type("cron.Schedule"))
.await?;
let source_key = cron_subscriptions[0].source_key.clone();
let cron_execution_scope = runtime_effect_controller.scoped_effect_controller(
ExecutionScope::runtime_operation("cron:daily-digest:2026-06-08T08:00:00Z"),
)?;
core.triggers()
.emit(
TriggerOccurrenceRequest::new(
"cron.Schedule",
source_key,
tick_payload,
"cron:daily-digest:2026-06-08T08:00:00Z",
),
cron_execution_scope,
)
.await?;
let button_execution_scope = runtime_effect_controller.scoped_effect_controller(
ExecutionScope::runtime_operation("button:red:2026-06-08T12:00:00Z"),
)?;
core.triggers()
.emit(
TriggerOccurrenceRequest::new(
"ui.button.pressed",
empty_trigger_source_key("ui.button.pressed")?,
button_payload,
"button:red:2026-06-08T12:00:00Z",
),
button_execution_scope,
)
.await?;
Terminator Effects
finish and fail are ability operations too, not VM-local short-circuits. The VM hands the terminal value to ExecutionHost::perform as AbilityOp::Finish or Fail before unwinding. Default handlers pass the value through unchanged; custom handlers can transform it, observe it, or record it for replay. The VM still enforces divergence: the handler may change the value but cannot prevent termination.
AbilityOp::Finish(value)- The single program terminator, emitted by
finish valuein both foreground blocks and namedprocessbodies. The VM'sVmModedecides the outcome: a foreground block producesVmOutcome::Finished(running once per paired<lashlang>block) and a process body producesVmOutcome::ProcessFinished, with both mapping toExecutionOutcome::Finished. The handler-returned value becomes the terminal output, and RLM's host bridge passes it through unchanged. The earlier foregroundsubmitkeyword has been removed; the parser rejects it. AbilityOp::Fail(value)- Process-mode failure path emitted by
fail value. The handler may transform the value but the outcome stays on the failure path (ExecutionOutcome::Failed). - Handler errors
- If a handler returns
Errfor a terminator op, the VM surfaces it as aRuntimeError::ValueErrorwith the op name and the host's message. ReturningAbilityResult::Uniterrors with "finish returned no value" via the sharedinto_valuehelper.
Fanout With Await
Independent module operations fan out through aggregate await. Process work still starts as handles first, then joins through await.
- Module operation fanout
await { first: web.search({ query: "a" }), second: web.search({ query: "b" }), label: "kept" }batches direct receiver-call leaves, preserves pure value leaves, runs operations through the host scheduler, and reconstructs the same record/list shape. Put?on each operation leaf to unwrap it after the whole batch finishes.- Process handle joins
await { first: h1, second: h2 }resolves handles and returns wrapper records keyed by name.await [h1, h2]resolves handles in list order. Start all independent process handles before joining them.
results = await {
one: web.search({ query: "Read file A" })?,
two: web.search({ query: "Read file B" })?
}
finish { a: results.one, b: results.two }
Builtins
Builtins are pure value helpers except for validation errors. They are called as ordinary functions and are optimized by the compiler where static type literals allow it.
| Builtin | Behavior |
|---|---|
len(x), empty(x) | Length/emptiness for strings, lists, records, and projected lists. len(null) is 0. |
slice(x, start, end) | Substring or sublist. null bounds mean start/end; negative bounds count from the end. |
range(end), range(start, end), range(start, end, step) | Integer lists with an exclusive end bound. step may be positive or negative, but not 0. |
ceil_div(a, b), floor_div(a, b) | Finite-integer division helpers for chunk/count math; b must not be 0. |
push(list, item) | Returns a new list with item appended. |
split, join, trim | Common string shaping helpers. |
find(s, needle, start?) | Returns the zero-based character index of the first literal match, or null. start defaults to 0 and is a non-negative character index. An empty needle returns start when it is in bounds. |
grep_text(s, needle) | Literal in-memory line search; needle must be non-empty. Returns one record per matching line: { line: int, text: str, match: str, start: int, end: int }; line is 1-based, text is the line without its line ending, and start/end are zero-based character offsets within that line's text, with end exclusive. |
starts_with, ends_with, contains | Prefix/suffix checks plus membership for strings, lists, projected lists, and record keys. |
keys(record), values(record) | Record key and value extraction in record order. |
to_string, to_int, to_float | Conversion helpers. to_string(image) emits metadata, not binary image data. |
json_parse(s) | Parses JSON into Lashlang values. |
format(template, ...args) | Positional interpolation with {}, explicit slots like {0}, and escaped braces via {{/}}. |
validate(value, Type { ... }) | Returns value unchanged when it matches the type literal; aborts with a validation error otherwise. |
Type Literals
Type { ... } values describe structured records for validation and dynamic tool output contracts. They are runtime values, not a static type checker for all code.
- Shapes
- Scalars:
str,int,float,bool,dict,any,null. Collections:list[shape],enum["a", "b"], nestedType { ... }, named refs, and unions with|. - Optional vs nullable
email: str?means the field may be absent, but if present it must be a string.email: str | nullmeans the field is required and may benull.- Nested objects
- Nested record shapes must use the
Typekeyword:profile: Type { name: str }. A bare{ name: str }is a record value form and is rejected in type positions. - Contract boundary
- Tools such as
llm_queryandspawn_agentcan expose output schemas derived from these shapes, but Lashlang v1 keeps this as contract truthfulness rather than model-authored generics.
Payload = Type {
id: str,
score: float | null,
tags: list[str],
status: enum["new", "done"],
note: str?
}
validated = validate(candidate, Payload)
Type literals can be passed directly to tools that accept output schemas, and they double as runtime guards on JSON parsed from untrusted text. Validation errors carry a JSONPath-style path (rooted at $, with .field and [index] segments) to the bad field.
Package = Type {
name: str,
version: str,
labels: list[str],
meta: Type {
pages: int,
published: int
}
}
raw = await workspace.default.read_file({ path: "package.json" })?
parsed = json_parse(raw)
package = validate(parsed, Package)
// Constrain a subagent's output shape via the same Type literal.
result = await agents.spawn({
task: format("summarize {} v{}", package.name, package.version),
capability: "summarize",
output: Type { headline: str, bullets: list[str] }
})?
finish { package: package, summary: result }