SLANG is a minimal, LLM-native meta-language for orchestrating multi-agent workflows. It is designed to be:
- Readable by anyone on the team — PMs, analysts, developers, and LLMs alike
- Executable by an LLM directly (zero-setup, no code) or by a thin runtime (production)
- Composable — flows can import and nest other flows
SLANG has exactly three primitives: stake, await, commit/escalate.
Everything else is syntactic sugar over these three operations.
A flow is the top-level unit. It defines a named, self-contained multi-agent workflow.
flow "name" {
...agents...
...constraints...
}Flows can optionally declare typed parameters, turning the flow into a reusable function:
flow "analysis" (topic: "string", depth: "number") {
agent Analyst {
stake analyze(topic, depth: depth) -> @out -- parameters are resolved as values
commit
}
converge when: all_committed
}Parameter types ("string", "number", "boolean") are advisory; the runtime does not enforce them.
Values are passed via RuntimeOptions.params when calling runFlow.
A flow contains:
- One or more
agentdeclarations - Optional
convergecondition - Optional
budgetconstraint - Optional
importstatements
An agent is a named actor within a flow. Each agent has a sequence of operations.
agent Name {
...operations...
}An agent can optionally have:
- A
roledescriptor (natural language string describing its purpose) - A
modelpreference (e.g.,model: "claude-sonnet") - A
toolslist (e.g.,tools: [web_search, code_exec]) - A
retrycount (e.g.,retry: 3) — max LLM call attempts on failure
agent Researcher {
role: "Expert web researcher focused on primary sources"
model: "claude-sonnet"
tools: [web_search]
retry: 3
stake gather(topic) -> @Analyst
}stake <function>(<args...>) [-> @<recipient>]
[output: { field: "type", ... }]- Executes
functionwith the given arguments - Sends the result to
recipient(if specified) - The function name is a semantic label, not a code reference — it tells the LLM what to do
- Arguments can be literals, references to previous data, or natural language descriptions
- The optional
output:block declares a structured output contract — the runtime injects the schema into the LLM prompt, ensuring the response contains a JSON object with the specified fields
Multiple recipients:
stake analyze(data) -> @Critic, @LoggerBroadcast:
stake announce(result) -> @allOutput to flow:
stake summarize(findings) -> @outLocal execution (no recipient):
stake research(topic: "AI safety")When -> is omitted, the stake executes locally — the LLM is called and the result is stored in the agent's output, but nothing is sent to the mailbox or flow output. This is useful for intermediate computations.
The result of a stake (with or without recipient) can be captured into a variable using let or set:
let article = stake write(topic: "AI security")
set draft = stake revise(draft, feedback)
let result = stake analyze(data) -> @outWhen a binding is used, the LLM response is stored in the named variable and delivered to any specified recipients. This enables chaining multiple LLM calls within a single agent without needing await:
agent Writer {
let data = stake research(topic: "AI safety")
let summary = stake summarize(data)
stake publish(summary) -> @out
commit
}await <binding> <- @<source>- Blocks until
sourceproduces astakedirected at this agent - Binds the received data to
bindingfor use in subsequent operations
Multiple sources (wait for all):
await data <- @Researcher, @ScraperMultiple sources with count:
await results <- @Workers (count: 3)Any source:
await input <- @anyWildcard (from anyone):
await signal <- *commit <value>- Declares that
valueis the accepted output of this agent - Signals to the flow that this agent has converged
- A committed agent will not execute further operations
Conditional commit:
commit result if result.confidence > 0.8escalate @<target>- Declares that this agent cannot resolve the current task
- Delegates to
target(another agent or@Human) - Passes all accumulated context to the target
Conditional escalate:
escalate @Arbiter if confidence < 0.5With reason:
escalate @Human reason: "Conflicting data, need human judgment"Inline conditionals using if:
commit result if result.score > 0.8
escalate @Human if result.score <= 0.8Block conditionals using when:
when feedback.approved {
commit feedback
}
when feedback.rejected {
stake revise(draft, feedback.notes) -> @Validator
}A when block can have an optional else (or otherwise) branch for mutually exclusive conditions:
when feedback.approved {
commit feedback
} else {
stake revise(draft, feedback.notes) -> @Reviewer
}otherwise is an alias for else:
when data.valid {
commit data
} otherwise {
escalate @Human reason: "invalid data"
}The else block executes when the when condition is false. Without else, a false condition simply skips the block (backward compatible).
let name = expression
let name = stake func(args) -- execute & storeDeclares a new agent-local variable. Variables are scoped to the agent that declares them and persist across rounds.
When used with stake, the LLM call is executed and the result is stored in the variable:
agent Writer {
let data = stake research(topic: "AI")
let summary = stake summarize(data)
stake publish(summary) -> @out
commit
}set name = expression
set name = stake func(args) -- execute & updateUpdates an existing variable's value.
set attempts = 3
set summary = result.text
set ready = true
set draft = stake revise(draft, notes) -- re-generate via LLMVariables are resolved before bindings (from await) in expression evaluation. This means a variable and a binding with the same name will resolve to the variable value.
Variables are included in the agent's LLM prompt context as Agent variables: { name: value, ... }.
repeat until condition {
...operations...
}Executes the body repeatedly until condition evaluates to true.
agent Worker {
let done = false
repeat until done {
stake process(data) -> @Checker
await result <- @Checker
set done = result.approved
}
commit
}A safety limit of 100 iterations prevents infinite loops at runtime.
Defines when the flow terminates successfully:
converge when: committed_count >= 1
converge when: all_committed
converge when: @Analyst.committed && @Validator.committedHard limits on resource consumption:
budget: tokens(50000)
budget: rounds(5)
budget: tokens(50000), rounds(5)
budget: time(60s)When budget is exhausted, the flow terminates with a budget_exceeded status and returns whatever partial results exist.
Import another .slang file and run it as an embedded sub-flow. The sub-flow executes to completion before the parent flow's main loop begins. Its output is exposed as a synthetic committed agent named by the alias — any parent agent can receive the result using await.
flow "full-report" {
import "research.slang" as research -- runs sub-flow; alias = committed agent
agent Editor {
await findings <- @research -- receive sub-flow output
stake edit(findings, format: "markdown") -> @out
commit
}
converge when: all_committed
budget: tokens(300000), rounds(20)
}Runtime behaviour:
importLoader(path)callback is invoked with the literal path string.- The sub-flow is fully executed with the same adapter and tools.
- The sub-flow's
@outoutputs (or last committed agent outputs) become the alias agent's output. - The alias is registered as a committed agent in the parent flow state.
- If no
importLoaderis provided, theimportstatement is silently skipped.
Combining with parametric flows:
flow "pipeline" (topic: "string") {
import "gather.slang" as data
agent Writer {
await raw <- @data
stake write(raw, topic: topic) -> @out
commit
}
converge when: all_committed
}A deliver statement declares a handler to execute after the flow converges. Deliver statements are flow-level (siblings of agent, converge, budget) and are executed in declaration order.
deliver: handler_name(args)Common use cases:
- Save flow output to a file
- Send a webhook notification
- Log results to an external system
- Trigger downstream pipelines
flow "report" {
agent Writer {
role: "Technical writer"
stake write(topic: "AI Safety") -> @out
commit
}
deliver: save_file(path: "report.md", format: "markdown")
deliver: webhook(url: "https://hooks.example.com/done")
converge when: all_committed
}Deliver handlers are provided at runtime via the deliverers option in RuntimeOptions (same pattern as tools):
const state = await runFlow(source, {
adapter,
deliverers: {
save_file: async (output, args) => {
await writeFile(args.path as string, String(output));
},
webhook: async (output, args) => {
await fetch(args.url as string, {
method: "POST",
body: JSON.stringify(output),
});
},
},
});Each handler receives:
output— the last flow output (fromstake -> @out)args— the named arguments from thedeliver:statement
If a handler name in the .slang file has no matching entry in deliverers, it is silently skipped (backward compatible).
Deliver statements are not executed when the flow terminates with budget_exceeded, escalated, or deadlock status — only on successful convergence.
The onConverge callback is a runtime-level hook invoked after all deliver handlers complete:
const state = await runFlow(source, {
adapter,
onConverge: async (finalState) => {
console.log(`Flow converged in ${finalState.round} rounds`);
},
});This is useful for programmatic post-processing without needing a deliver statement in the .slang file.
The Finalizer pattern combines a dedicated agent for orchestration with deliver for side effects:
flow "report-with-delivery" {
agent Researcher {
role: "Web research specialist"
tools: [web_search]
stake gather(topic: "AI agent frameworks 2025") -> @Writer
}
agent Writer {
role: "Technical writer"
await data <- @Researcher
stake write(data, style: "executive summary") -> @out
output: { title: "string", body: "string" }
commit
}
deliver: save_file(path: "report.md", format: "markdown")
deliver: webhook(url: "https://hooks.example.com/reports")
converge when: committed_count >= 1
budget: rounds(3)
}See examples/finalizer.slang for a complete runnable example.
SLANG supports the following value types:
- Strings:
"hello","multi-word value" - Numbers:
42,3.14,0.8 - Booleans:
true,false - Lists:
["a", "b", "c"],[web_search, code_exec] - Identifiers:
result,feedback,data— references to bound variables - Dot access:
result.confidence,feedback.approved - Agent references:
@Analyst,@Human,@all,@any,@out
Function arguments are either positional or named:
stake gather("AI trends") -- positional
stake gather(topic: "AI trends") -- named
stake validate(data, against: ["rule1"]) -- mixedEach agent has implicit state accessible via dot notation:
@Agent.output— the last staked output@Agent.committed— boolean, whether the agent has committed@Agent.status—idle | running | committed | escalated
The flow has implicit state:
committed_count— number of agents that have committedall_committed— boolean, true when all agents have committedround— current round numbertokens_used— total tokens consumed (runtime only)
Single-line comments use --:
-- This is a comment
agent Researcher {
stake gather(data) -> @Analyst -- inline comment
}The runtime (LLM or thin scheduler) resolves execution order as follows:
- Parse all agents and their operations
- Build a dependency graph from
stake -> @Targetandawait <- @Source - Agents whose first operation is
stake(no precedingawait) are ready - Agents whose first operation is
awaitare blocked - Execute ready agents, collect outputs, satisfy awaits, repeat
Within each round, independent agents execute in parallel. The runtime partitions executable agents into two groups:
- Parallelizable — agents whose current operation is
stake. These are dispatched concurrently viaPromise.all, because they produce output via LLM calls and have no ordering dependency on each other. - Sequential — agents whose current operation is
await,commit,escalate, orwhen. These modify shared state (mailbox, agent status) and are executed one at a time.
This means that if three agents all need to call an LLM at the same time, the three API calls happen concurrently, significantly reducing wall-clock time.
Parallel execution can be disabled by passing parallel: false in RuntimeOptions (useful for debugging or deterministic replay).
The model field on an agent declaration is not just a hint — when using a router adapter, it determines which LLM backend handles that agent's calls. This enables flows where different agents run on different providers, endpoints, or even local models:
flow "hybrid-analysis" {
agent Researcher {
model: "gpt-4o" -- routed to OpenAI
stake gather(topic) -> @Analyst
}
agent Analyst {
model: "claude-sonnet" -- routed to Anthropic
await data <- @Researcher
stake analyze(data) -> @out
commit
}
converge when: all_committed
}The router adapter matches the model string against a list of pattern→adapter rules (first match wins). This is configured at the runtime level, not in the .slang file itself:
const router = createRouterAdapter({
routes: [
{ pattern: "claude-*", adapter: anthropicAdapter },
{ pattern: "gpt-*", adapter: openaiAdapter },
{ pattern: "local/*", adapter: ollamaAdapter },
],
fallback: openRouterAdapter,
});With this configuration, model: "claude-sonnet" routes to Anthropic, model: "gpt-4o" routes to OpenAI, model: "local/llama3" routes to a local Ollama instance, and any unmatched model falls back to OpenRouter — all within the same flow.
Available adapters: MCP Sampling, OpenAI, Anthropic, OpenRouter, Echo, Router.
When an agent declares retry: N, the runtime wraps each stake LLM call in a retry loop with exponential backoff:
- Attempt 1: immediate
- Attempt 2: after 1s
- Attempt 3: after 2s
- Attempt N: after min(2^(N-2)s, 8s)
If all attempts fail, the error propagates. The default is retry: 1 (no retry).
The runtime emits agent_retry events so callers can monitor retry behavior.
The output: block on a stake operation declares the expected schema of the LLM response:
stake review(draft) -> @Decider
output: { approved: "boolean", score: "number", notes: "string" }The runtime injects a JSON schema requirement into the system prompt, asking the LLM to include a ```json block with the specified fields. The resolveExprValue engine then extracts JSON from the response using a multi-stage pipeline:
- Try fenced ````json` block extraction
- Try raw
JSON.parseon the full response - Try extracting the first
{ ... }block from the response - Fall back to regex patterns for well-known fields (
confidence,approved,rejected,score)
This makes dot-access expressions like result.approved reliable even when the LLM wraps its JSON in prose.
The analyzeFlow() function performs extended static checks beyond deadlock detection:
| Check | Level | Description |
|---|---|---|
| Missing converge | warning | Flow has no converge statement |
| Missing budget | warning | Flow has no budget — default limits apply |
| Unknown recipient | error | stake directs to an agent not declared in the flow |
| Unknown source | error | await from an agent not declared in the flow |
| No commit | warning | Agent never commits — it will never signal completion |
The check_flow MCP tool now returns these diagnostics alongside deadlock analysis.
The runtime supports checkpointing — persisting a snapshot of the FlowState after each round so that a flow can be resumed from that point if the process crashes or is interrupted.
Pass a checkpoint function in RuntimeOptions:
const state = await runFlow(source, {
adapter,
checkpoint: async (snapshot) => {
// snapshot is a deep clone of the FlowState at this point
const json = serializeFlowState(snapshot);
await fs.writeFile('checkpoint.json', json);
},
});The callback is invoked:
- After each completed round (while the flow is still running)
- After the flow terminates (final state)
Pass a resumeFrom state to continue a previously interrupted flow:
const saved = deserializeFlowState(
await fs.readFile('checkpoint.json', 'utf8')
);
const state = await runFlow(source, {
adapter,
resumeFrom: saved,
});The runtime skips agent initialization and continues from the stored opIndex, bindings, mailbox, and round values.
FlowState contains Map objects which are not JSON-serializable. The runtime exports two helpers:
serializeFlowState(state: FlowState): string— convertsMapinstances to a JSON-safe representationdeserializeFlowState(json: string): FlowState— restoresMapinstances from JSON
The runtime emits { type: "checkpoint", round } events when a checkpoint occurs.
When an agent declares tools: [web_search, code_exec], the runtime can make those tools functional — actually invoking real tool handlers during a stake operation.
Tool handlers can be provided via the API or via the CLI.
API — pass a tools record in RuntimeOptions:
const state = await runFlow(source, {
adapter,
tools: {
web_search: async (args) => {
const results = await search(args.query as string);
return JSON.stringify(results);
},
code_exec: async (args) => {
return eval(args.code as string);
},
},
});CLI — pass a JS/TS file with --tools:
slang run research.slang --adapter openrouter --tools tools.jsThe file must default-export (or module-export) an object where each key is a tool name and each value is an async function (args: Record<string, unknown>) => Promise<string>. See examples/tools.js for a ready-to-use template.
For deliver: handlers, use --deliverers instead:
slang run report.slang --adapter openrouter --deliverers deliverers.jsThe deliverers.js file must default-export an object where each key is a handler name matching a deliver: statement and each value is async (output: unknown, args: Record<string, unknown>) => void. See §2.9 for details.
- The runtime checks each agent's
tools:declaration against the providedRuntimeOptions.toolsrecord - Only tools that appear in both the agent declaration and the runtime options are made available
- Available tool names are injected into the system prompt with a calling convention:
TOOL_CALL: web_search({"query": "AI trends"}) - After the LLM responds, the runtime scans for
TOOL_CALL:patterns - If found, the matching handler is invoked, the result is appended to the conversation, and the LLM is called again
- This loop continues until the LLM responds without a
TOOL_CALLor until 10 tool calls are reached (safety limit)
The runtime emits two event types:
{ type: "tool_call", agent, tool, args }— before executing a tool handler{ type: "tool_result", agent, tool, result }— after the handler returns
If tools: is declared in the .slang file but no matching handlers exist in RuntimeOptions.tools, the tools are silently ignored (backward compatible with v0.2).
Zero-Setup Mode: An LLM reads the flow and executes it turn-by-turn in a single conversation, simulating each agent in sequence. The LLM maintains state as structured text.
Thin Runtime Mode: A scheduler program parses the flow, maintains state, and dispatches each agent as a separate LLM call. Supports real tools, parallel execution, and different models per agent.
A flow terminates when:
- The
convergecondition is met (success) - The
budgetis exhausted (partial result) - An
escalate @Humanis reached (human-in-the-loop) - A deadlock is detected — no agent can proceed (error)
A round is one full pass through all currently executable agents. The budget: rounds(N) constraint limits how many full passes occur. Within a round, independent agents run in parallel (see §5.2).
SLANG has built-in support for testing flows via the expect statement and a mock adapter.
expect is a flow-level item (sibling of agent, converge, budget). It declares a boolean assertion that is evaluated after flow execution:
expect @Agent.output contains "expected"
expect @Agent.committed == trueThe contains keyword is a binary operator that tests string containment. The left operand is converted to a string and checked for inclusion of the right operand:
expect @Writer.output contains "conclusion"
when @Reviewer.output contains "approved" { commit }The mock adapter (createMockAdapter) provides deterministic, per-agent responses for testing. It inspects the system prompt for the agent name and returns the configured response:
const adapter = createMockAdapter({
responses: {
Writer: "Hello world — conclusion reached",
Reviewer: "Approved",
},
defaultResponse: "ok",
});The testFlow(source, options) function parses a flow, runs it with a mock adapter, then evaluates all expect statements:
const result = await testFlow(source, {
mockResponses: { Agent: "response" },
});
// result.passed: boolean
// result.assertions: { passed, message, line, column }[]slang test flow.slang
slang test flow.slang --mock "Agent1:response1,Agent2:response2"Runs the flow with a mock adapter and evaluates all expect statements. Exits with code 0 if all pass, 1 if any fail.
When a flow contains expect statements, the playground automatically uses testFlow with a mock adapter when RUN is clicked, displaying test results alongside the flow output.
flow, agent, stake, await, commit, escalate, import, as,
when, if, else, otherwise, converge, budget, role, model, tools,
tokens, rounds, time, count, reason, retry, output, deliver,
let, set, repeat, until, expect, contains,
true, false,
@out, @all, @any, @Human
SLANG files use the .slang extension.
flow "hello" {
agent Greeter {
stake greet("world") -> @out
commit
}
converge when: all_committed
}flow "review" {
agent Writer {
let approved = false
stake write(topic: "SLANG benefits") -> @Reviewer
repeat until approved {
await feedback <- @Reviewer
when feedback.approved {
set approved = true
commit feedback
} else {
stake revise(feedback.notes) -> @Reviewer
}
}
}
agent Reviewer {
let done = false
repeat until done {
await draft <- @Writer
let result = stake review(draft, criteria: ["clarity", "accuracy"]) -> @Writer
output: { approved: "boolean", notes: "string" }
set done = result.approved
}
commit
}
converge when: committed_count >= 1
budget: rounds(5)
}flow "research" {
agent Researcher {
role: "Web research specialist"
tools: [web_search]
stake gather(topic: "quantum computing 2026") -> @Analyst
}
agent Analyst {
role: "Data analyst and strategist"
await data <- @Researcher
stake analyze(data, framework: "SWOT") -> @Critic
await verdict <- @Critic
commit verdict if verdict.confidence > 0.7
escalate @Human reason: "Low confidence analysis" if verdict.confidence <= 0.7
}
agent Critic {
role: "Adversarial reviewer"
await analysis <- @Analyst
stake challenge(analysis, mode: "steelmanning") -> @Analyst
}
converge when: committed_count >= 1
budget: tokens(40000), rounds(4)
}The SLANG toolchain uses a structured error code system. All errors carry a code, a human-readable message, and source location (line/column).
| Range | Component | Description |
|---|---|---|
| L1xx | Lexer | Tokenization errors |
| P2xx | Parser | Syntax errors |
| R3xx | Resolver | Static analysis warnings/errors |
| E4xx | Runtime | Execution errors |
| Code | Description |
|---|---|
| L100 | Unterminated string literal |
| L101 | Unexpected character |
| L102 | Expected agent name after @ |
| Code | Description |
|---|---|
| P200 | Unexpected token |
| P201 | Expected specific token |
| P202 | Expected expression |
| P203 | Expected operation (stake, await, commit, escalate, when, let, set, repeat) |
| P204 | Expected flow body item (import, agent, converge, budget, deliver) |
| P205 | Expected budget kind (tokens, rounds, time) |
| P206 | Expected agent name |
| P207 | Expected flow name |
| P208 | Unclosed block |
| Code | Description |
|---|---|
| R300 | Unknown agent reference |
| R301 | Deadlock detected (circular dependency) |
| R302 | Agent has no commit |
| R303 | Orphan agent (produces but nobody consumes) |
| R304 | Missing converge statement |
| R305 | Missing budget statement |
| Code | Description |
|---|---|
| E400 | No flow found in source |
| E401 | LLM adapter call failed |
| E402 | Budget exceeded |
| E403 | Runtime deadlock |
| E404 | Tool handler not found |
| E405 | Tool execution error |
| E406 | All retries exhausted |
| E407 | Test assertion failed |
The parser supports an error recovery mode via parseWithRecovery(source). Instead of throwing on the first error, it collects all errors and returns a partial AST:
const { program, errors } = parseWithRecovery(source)
// program: partial AST (may contain synthetic/dummy nodes)
// errors: ParseError[] with code, line, column, messageThis is used by the playground and IDEs for real-time feedback. For production use, the standard parse(source) function still throws on the first error.
slang init [dir] creates a new SLANG project with the following files:
| File | Description |
|---|---|
hello.slang |
Minimal hello-world flow |
research.slang |
Research flow with tools |
tools.js |
Stub tool handlers (web_search, code_exec) |
.env.example |
Environment variable template |
If a file already exists, it is skipped. The command is idempotent.
The SLANG CLI loads a .env file from the current working directory automatically at startup. This file uses the standard KEY=VALUE format:
# Comments start with #
SLANG_ADAPTER=openrouter
OPENROUTER_API_KEY=sk-or-...
SLANG_MODEL=openai/gpt-4oRules:
- Lines starting with
#and blank lines are ignored - Values may be optionally quoted with
"or' - Real environment variables take precedence over
.envvalues - The file is optional — the CLI works without it
Supported variables:
| Variable | Description |
|---|---|
SLANG_ADAPTER |
Default adapter (openai, anthropic, openrouter, echo) |
SLANG_API_KEY |
API key (generic, used by any adapter) |
SLANG_MODEL |
Default model override |
SLANG_BASE_URL |
Custom base URL for OpenAI-compatible endpoints |
OPENAI_API_KEY |
OpenAI API key |
ANTHROPIC_API_KEY |
Anthropic API key |
OPENROUTER_API_KEY |
OpenRouter API key |
By default, the CLI runs in silent mode: round-by-round agent outputs are hidden, only a progress indicator and the final result are shown. This keeps the terminal clean for production use.
To enable debug mode with full round-by-round output (round headers, agent operations, LLM responses, tool calls), pass --debug:
slang run flow.slang --adapter openrouter --debugThe final result section (status, rounds, tokens, agent outputs) is always displayed regardless of mode.