Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Runlet logo

Runlet

Runlet is a small orchestration language for LLM agents. It lets an agent replace a sequence of individual tool calls with one program that the host can check, execute concurrently, observe, and return as a single structured result.

issues = linear.search_issues({ assignee: user, state: "open" })
pulls = github.search_pull_requests({ author: user, state: "open" })
pulls_with_checks = for pull in pulls {
checks = github.checks(pull.number)
return {
number: pull.number,
title: pull.title,
checks
}
}
return {
issues,
pull_requests: pulls_with_checks
}

There is no async or await. References create data dependencies: the Linear and GitHub searches can start together, and each checks lookup starts when its pull request is available. The host bounds fan-out; only work that contributes to the returned value runs.

Why a language for tool composition?

A conventional agent loop sends every tool result back through the model. For a multi-step task, that means repeated inference round-trips, a growing transcript full of intermediate data, and asking the model to manually carry state between calls.

Runlet moves that mechanical work into the agent runtime:

model → one Runlet program → many host tool calls → one structured result

The model still decides what should happen. Runlet gives that decision a small, purpose-built execution format instead of making the model supervise every iteration, dependency, retry, and join.

This is especially useful for:

  • list-then-detail and other N+1 API access patterns;
  • joining, projecting, and enriching structured results;
  • independent calls that should run concurrently;
  • bounded fan-out across many records;
  • read/transform/write workflows;
  • retryable operations with an explicit fallback; and
  • joining data from built-in tools, MCP servers, and application APIs.

The motivation is borne out by AgentKit's compose case study. Its sandboxed Lua composition tool reduced model round-trips, context growth, and cost for tool-heavy tasks. On the study's initial six-scenario run, composition reduced cost by 38–77% while maintaining or improving accuracy. The broader model sweep also found an important boundary: composition was consistently valuable for N+1 fan-out, but could be counterproductive for exploratory investigations. Runlet targets the same runtime-enrichment mechanism with schemas, implicit dataflow, structured concurrency, and an inspectable execution graph built into the language.

Design goals

GoalHow Runlet approaches it
Make correct concurrency the defaultTool outputs behave like ordinary values; references create graph edges and independent nodes can run together.
Keep programs easy for models to produceThe language has bindings, expressions, objects, lists, for, conditionals, boundaries, and return—but no imports, classes, functions, threads, or visible type syntax.
Catch mistakes before tools runThe host registers every tool with input and output schemas. The analyzer checks names, calls, projections, operators, branches, and returned values.
Avoid accidental effects, keep intended onesPure work is lazy and root-reachable: an unused named pure computation is pruned with a warning, while _ = expression explicitly evaluates and discards any result. Statements containing effectful calls and discard statements are implicit roots — independent roots run concurrently, while after prerequisite { ... } expresses required ordering.
Bound dynamic workThe host caps active for iterations while preserving result order.
Make failure handling explicitboundary retry N { ... } catch err { ... } owns a subgraph, retries retryable failures, and produces a normal fallback value.
Let hosts retain controlThe embedder supplies the complete tool registry, implementations, schemas, execution policies, and external inputs.
Make execution observableThe runtime emits ordered graph events for nodes, dependencies, attempts, failures, recoveries, and results.

Language tour

Runlet programs are immutable expressions ending in one return:

customer = crm.get_customer(customer_id)
orders = commerce.list_orders({ customer_id: customer.id })
enriched = for order in orders {
result = boundary retry 2 {
return risk.score({ customer, order })
} catch err {
return {
status: "unavailable",
code: err.code,
attempt: err.attempt
}
}
return {
id: order.id,
total: order.total,
risk: result
}
}
return {
customer: customer.name,
orders: enriched
}

The main rules are deliberately compact:

  • Assignments create immutable bindings. _ = expression discards the value but always evaluates the expression; it may repeat and introduces no readable name.
  • A program and every block end with return.
  • Objects and lists are ordinary structured values; { [expr]: value } computes a property key, and left + right merges two objects shallowly (the right side wins).
  • value if condition else alternative evaluates only the selected branch; else is optional and defaults to null. For larger branches, if cond { ... } else { ... } is the block-bodied expression form — each branch ends with return.
  • for item in items { ... } returns an ordered list with host-bounded concurrency; skip if condition drops an element.
  • fold acc = init for item in items { ... } reduces sequentially; the body's return becomes the next accumulator, while break value if condition stops early with a final accumulator.
  • Independent implicit effect and discard roots run concurrently. after prerequisite { ... return value } gates calls lexically created in its block (including nested branch/loop calls) without retroactively gating calls declared outside it. Lists or objects can join multiple prerequisites.
  • boundary retry N { ... } catch err { ... } turns a failed subgraph into a fallback value; fail(code, message) raises one.
  • assert(condition, message) checks an invariant without returning its intermediate values.
  • Tool namespaces such as crm.get_customer come entirely from the host.
  • A small pure intrinsic library (text.*, regex.*, list.*, json.*, number.*, time.*) covers data shaping; see STDLIB.md.

See examples/ for executable programs and DESIGN.md for the complete language and runtime semantics.

Embedding Runlet

The Rust crate provides the parser, analyzer, schemas, canonical values, runtime, and execution graph. A host describes its tool surface, connects each descriptor to an implementation, compiles agent-produced source, and executes the resulting program:

use runlet::{CallSchema,CanonicalValue,ExecutionPolicy,Runtime,Schema,ToolDescriptor,ToolRegistry,};fnmain(){letmut tools = ToolRegistry::new();
tools.register(ToolDescriptor{name:"profile.lookup".into(),summary:"Look up a user profile".into(),input:CallSchema::positional(vec![Schema::string()]),output:Schema::Any,execution:ExecutionPolicy::Pure,schema_version:"1".into(),}).unwrap();let runtime = Runtime::builder().registry(tools).input("user_id",Schema::string(),"usr_123".into()).tool("profile.lookup", |args, _context| {letCanonicalValue::String(id) = &args[0]else{unreachable!()};Ok(CanonicalValue::Object([("id".into(), id.clone().into()),("name".into(),"Ada".into()),].into()))}).build().unwrap();let program = runtime.compile("profile = profile.lookup(user_id)\nreturn profile").unwrap();let execution = runtime.run(&program).unwrap();println!("{}", execution.value.presentation_json().unwrap());}

Tool handlers also receive stable operation, dispatch, schema-version, and attempt context. run_observed exposes the live graph event stream for logs, traces, dashboards, or an agent UI. .with_prelude() installs the deterministic intrinsics from STDLIB.md (host registrations of the same names win), and .retry_backoff(base, factor, cap) configures exponential backoff between boundary retry attempts.

Enriching an AgentKit runtime

AgentKit provides the surrounding agent loop, model adapters, tools, permissions, MCP integration, reporting, compaction, and task management. Runlet is intended to sit at the composition boundary of that runtime:

  1. Adapt the AgentKit tool catalog into Runlet ToolDescriptors.
  2. Expose a runlet composition tool to the model alongside the granular tools.
  3. Compile the submitted program against the tools visible for that turn.
  4. Dispatch Runlet call nodes through AgentKit's existing executor so permissions, approvals, cancellation, and reporting remain authoritative.
  5. Return only the program's final structured value to the model transcript.

That integration is not part of this repository yet; the current crate provides the executable language model and an in-process threaded executor. The separation is intentional: Runlet plans and explains the work, while the agent host owns capabilities and policy.

Compact results with TOON

Tool composition prevents intermediate results from filling the transcript. The final result can be made smaller too. The serde_toon2 crate provides Serde-compatible Token-Oriented Object Notation, so a host can encode the structured Runlet result before adding it to the next model turn:

let json = execution.value.presentation_json()?;let value: serde_json::Value = serde_json::from_str(&json)?;let model_context = serde_toon2::to_string(&value)?;

Together, the three projects cover distinct layers of the enrichment story:

  • AgentKit runs the model loop and governs tools.
  • Runlet composes tool work into a checked execution graph.
  • serde_toon2 encodes the selected result for efficient model context.

Run the project

Run the language examples with the built-in CLI:

cargo run -- ./examples/03_loops.rnlt
cargo run -- ./examples/05_boundaries.rnlt

Watch a larger concurrent pipeline as a live execution graph:

cargo run -- graph ./examples/live/pipeline.rnlt

Run the test suite:

cargo test

The current implementation includes parsing, schema analysis, canonical values, lazy root-reachable execution, dynamic branches, bounded concurrent loops, retry boundaries, and live graph events. Durable journals, recovery, cancellation, and production executor interfaces remain roadmap work described in DESIGN.md.

Editor support

  • editors/vscode provides a dependency-free VS Code extension and reusable TextMate grammar.
  • editors/zed provides a Zed extension backed by Tree-sitter.
  • editors/vim provides Vim and Neovim syntax support.
  • editors/tree-sitter-runlet contains the Tree-sitter grammar source; generated parser files live only on the tree-sitter branch.

License

Runlet is available under the MIT or Apache-2.0 license.

About

Runlet orchestration language and runtime

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages