lashlang/effects

How Lashlang programs reach the outside: host-projected bindings, resource operations, process handles, triggers, async fanout, builtins, and type literals.

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 public RlmProjectedBindings, restored projection refs, and the live history projection.
RlmProjectedBindings
RLM's public binding API. bind_json and bind_value store inline values; bind_lazy accepts a stable ProjectionRef, 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 via register_memory; those refs are only valid while the registry instance lives, and missing refs fail clearly.
ProjectedValue
Either a scalar FlowValue or a custom Arc<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_many queries on demand: each is a ProjectedReadRequest (such as Field, Index, Len, Render, or Materialize) answered with a ProjectedReadResponse, so large host-side structures need not be cloned into Lashlang values.

Reserved names

Reserved names
history and any host-projected globals are reserved. Attempting name = ... over a projected name raises `name` is a read-only projected binding. The host-side seed channel (continue_as / spawn_agent seed:) 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's ToolArgumentProjectionPolicy. Tool implementations should parse the normalized arguments as plain JSON. The default policy materializes projected values. continue_as and spawn_agent declare a seed-preserving policy, so only root seed: 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 to board.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 as cron.Schedule remain 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 a LashlangHostCatalog) under the lashlang.surface extension id: configured trigger-source constructors such as cron.Schedule(...), typed resource constructors, and other host descriptor constructors. Zero-config trigger sources such as ui.button.pressed({}) are added to the session catalog from reg.triggers().declare(...). Session plugins can still register normal tools through reg.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_annotations allows @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 and start name(...) creates one process run. The returned handle addresses that run.
processes.list
await processes.list({})? returns visible running runs. Optional filters include definition and status, for example await processes.list({ definition: daily_digest })? or await processes.list({ status: "completed" })?.
cancel
cancel handle asks 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

await
await handle resolves 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 host before execution, so a disabled process, start, sleep, wait_signal, signal_run, trigger registry operation, or @label annotation 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 value in both foreground blocks and named process bodies. The VM's VmMode decides the outcome: a foreground block produces VmOutcome::Finished (running once per paired <lashlang> block) and a process body produces VmOutcome::ProcessFinished, with both mapping to ExecutionOutcome::Finished. The handler-returned value becomes the terminal output, and RLM's host bridge passes it through unchanged. The earlier foreground submit keyword 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 Err for a terminator op, the VM surfaces it as a RuntimeError::ValueError with the op name and the host's message. Returning AbilityResult::Unit errors with "finish returned no value" via the shared into_value helper.

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.

BuiltinBehavior
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, trimCommon 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, containsPrefix/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_floatConversion 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"], nested Type { ... }, 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 | null means the field is required and may be null.
Nested objects
Nested record shapes must use the Type keyword: 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_query and spawn_agent can 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 }
read on ·