Runtime
Tool System
In Luma a tool is a construct in which the contract is separated from the implementation: the .luma file declares the tool's signature, its security class and its resilience policy; the real code lives in a JS/TS module on the host side. The runtime takes over everything in between — validation, permission, timeout, retry, cache, telemetry.
The tool contract
schema CalcResult {
result: float
}
tool calculate(
a: float @range(0, 1000000),
b: float @range(0, 1000000),
operation: enum["add", "multiply"] @desc("The operation to perform")
) -> CalcResult {
side_effects: "none"
cache: true
}| Part | Rule |
|---|---|
| Parameters | Must be typed. Field annotations can be used (@desc, @range, @default…). An input schema is generated from them automatically. |
| Return type | Must be a reference to a declared schema — including a tagged by union, see below. A primitive or an inline type is not accepted. |
| Body | Configuration keys only. It contains no code — it may be left empty. |
| Optional parameter | limit?: int @default(10) — filled in when absent, and not listed as required in JSON Schema. |
An internal schema named __tool_<name>_input is built from the parameters. That schema is used both for local input validation and for the parameters field of the tool declaration sent to the model — so your parameter annotations are visible to the model as well.
Tool options
| Key | Values | Default | Description |
|---|---|---|---|
side_effects | none · read · write | none | The tool's side-effect class. tool_policy is built on top of it. |
cache | true · false | false | Keeps the result for the same input inside the process. |
scope | string | — | Permission label. It must appear in the agent's tool_scopes list. |
credential | ENV name | — | If this environment variable is missing, the tool is never executed. |
error | schema name | — | The typed error schema that the error thrown by the handler is validated against. |
retries | int | 0 | Number of extra attempts (only on retryable errors). |
retry_backoff | none · fixed · exponential | none | The waiting strategy between attempts. |
retry_delay | ms | 100 | The base wait duration. |
timeout | ms | — | A tool-specific timeout; it overrides the agent's tool_timeout. |
idempotent | true · false | false | Run only once for the same key. |
idempotency_key | input field path | — | Mandatory with idempotent: true. E.g. request_id. |
idempotency_ttl_ms | ms | 300000 | How long the record stays valid. |
needs <name>: <Type> | declaration | — | A dependency to be supplied by the host (a DB connection, a credential set…). Repeatable. |
Consistency rules
| Rule | If violated |
|---|---|
| A write tool cannot be cached | Write tool 'x' cannot use cache. |
| A write tool must be idempotent to be retried | A write tool 'x' must be idempotent and specify an idempotency_key to be retried. |
idempotent requires a key | Idempotent tool 'x' must specify an idempotency_key. |
timeout: 0 is invalid | Tool 'x' timeout must be greater than zero. |
A tool that uses needs cannot be cached | Cache cannot be used on a tool that uses needs: x (at runtime) |
Direct tool call
A direct call is guaranteed: it does not depend on the model's decision, it runs where and in the order you wrote it in the source.
agent Flow {
tools: [text_stats, calculate]
fn run(text: string) -> Answer {
stats = tool text_stats({ text: text })
if stats.words > 3 {
bonus = tool calculate({ a: stats.words, b: 2, operation: "multiply" })
}
ask "Statistics: {stats}, bonus: {bonus}" -> Answer
}
}| Form | Meaning |
|---|---|
x = tool name({ a: 1, b: 2 }) | Call with an object literal. |
x = tool name(variable) | Call with an object variable you already have. |
x: Result = tool name({ … }) | Additionally validate the result against a type. |
- The tool must be in the agent's
toolslist; otherwiseAgent 'A' does not allow tool 'x' to be called directly. - It can be called at the top level too:
result = tool calculate({ a: 2, b: 3, operation: "add" }) - Input and output are validated separately; the error carries a
TOOL_INPUT_INVALID/TOOL_OUTPUT_INVALIDcode. - The two sides are not symmetric about
@severity: a soft rule on the output flags the value and lets the run continue, while the same rule on the input is ignored and stays hard. A tool is side-effecting code, and calling it with data known to break its contract is the thing the rule exists to prevent.
Native tool calling
An agent's tools list also makes those tools visible to the model. If the model decides to call a tool, the runtime executes it and sends the result back.
agent MultiToolWorker {
model: GPT54Mini
tools: [text_stats, calculate]
tool_rate_limit: 2
tool_concurrency: 2
fn run() -> FinalResult {
ask "Call both tools exactly once. Call text_stats with text='Luma DSL', and calculate with a=20, b=22, operation='add'. Once you have the tool results, return valid JSON only." -> FinalResult
}
}Multi-turn flow
1. request → messages + tools + tool_choice: "auto" (or the forced tool)
no response_format is sent
↓
2. response → assistant message + tool_calls[]
↓
3. runtime → per tool call: policy → input validation → execution → output validation
(as many at once as tool_concurrency allows)
↓
4. messages ← assistant(tool_calls) + one tool message per tool result
↓
5. request → messages + tools + response_format: json_schema (strict)
↓
6. response → final JSON → local schema validationSo that the model is able to call a tool, response_format is not sent on the first request; the strict JSON Schema is added only after at least one tool call has taken place.
The practical consequence: in an agent that declares tools, if the model answers directly without calling any tool at all, that response was not constrained in terms of format by the gateway — only Luma's local validation applies. Setting retry is especially valuable in this case.
By default the request goes out with tool_choice: "auto" and the model decides. Append using tool <name> to an ask to require that one tool instead — the model still produces the arguments, but the gateway is told the call is mandatory:
fn settle(text: string) -> Answer {
ask "Settle the account: {text}" -> Answer using tool calculate
}The tool has to be in the agent's tools list, otherwise the parser fails with Agent 'A' does not allow the tool 'calculate' required by using tool. Each adapter is told in its own dialect — see Provider mapping. When you want a step to run for certain without the model choosing its arguments, a direct tool call is still the simpler answer.
Provider mapping
| Provider family | Model → tool call | Runtime → result |
|---|---|---|
| OpenAI-compatible · Azure | tool_calls[] | role: "tool" message |
| Anthropic | tool_use block | user message containing tool_result |
| Gemini | functionCall part | functionResponse part |
This conversion is performed automatically in the adapter layer; nothing changes on the .luma side.
Limits
| Setting | Scope |
|---|---|
tool_rate_limit 5 | The total number of native tool calls within one ask flow. It resets on every new ask (and on every retry attempt). |
tool_concurrency | How many tools run concurrently within a single provider turn. |
tool_failure_limit | After this many failures the circuit breaker opens. |
Implementation: the tool registry
A tool must have a contract on the .luma side and a handler on the host side. Without a handler the program does not start:
Error: 'calculate' is not registered in the tool registry.
Handler signature
export type ToolHandler = ( input: any, // validated input signal?: AbortSignal, // timeout / cancellation signal needs?: Record<string, any>, // dependencies declared in the contract ) => any | Promise<any>;
// tools.ts
export const calculate = async (input, signal, needs) => {
if (input.operation === "add") return { result: input.a + input.b };
if (input.operation === "multiply") return { result: input.a * input.b };
throw new Error("Unsupported operation");
};Throwing a typed error
schema ToolError {
code: string
message: string
retryable: bool
details?: any
}
tool db_write(table: string, value: string) -> DbWriteResult {
side_effects: "write"
error: ToolError
retries: 3
retry_backoff: "exponential"
idempotent: true
idempotency_key: request_id
}import { LumaToolError } from "./src/tool-registry";
throw new LumaToolError({
code: "DB_TIMEOUT",
message: "The database timed out",
retryable: true, // → the retry loop continues
details: { table: input.table },
});retryable: true→ retried as many times asretriesallows.retryable: false→ both the tool retry and the LLM retry loop stop immediately.- If an
error:schema is given, the thrown error is validated against that schema; if it does not match,TOOL_ERROR_INVALID. - The error object is passed into the
try/catchvariable as is.
Tool module loading
The default handlers live in src/tool-registry.ts. You can add your own module in two ways:
luma program.luma --tool-module ./tools.ts
{
"tool_modules": [
{
"path": "./tools.ts",
"version": "1.0.0",
"integrity": "sha256-<base64-sha256>",
"dependencies": ["node-fetch"],
"dependency_root": "./vendor"
}
]
}| Manifest field | Meaning |
|---|---|
path | The module file (relative to the program directory). |
version | Semver metadata. Loading the same file a second time with different metadata is an error. |
integrity | sha256-<base64>. If the file contents do not match, loading is rejected. |
dependencies | The allowlist of packages the module may import/require. If a list is given, dynamic import() is forbidden. |
dependency_root | The filesystem root the dependencies may be read from (it cannot leave the project). |
The short form is valid too: "tool_modules": ["./tools.ts"]
| File type | Loading |
|---|---|
.ts · .mts | Transpiled with typescript and then loaded (the runtime dependency is required). |
.js · .cjs | Loaded as CommonJS. |
.mjs, or a .js whose package.json has "type": "module" | Loaded as ESM. |
Export shape
The module must export one of these keys (they are looked up in order) or itself directly as an object: lumaTools → tools → default → the module itself. Every value must be a function.
export const lumaTools = {
calculate: (input) => ({ result: input.a + input.b }),
text_stats: (input) => ({
characters: input.text.length,
words: input.text.trim().split(/\s+/).length,
}),
};The same name cannot be defined twice in the registry: Tool registry conflict: calculate.
datasource / query / command (v0.4.0)
Luma owns the typed database contract, not the driver. Three declarations — datasource, query, command — describe the boundary; the ~20-line bridge that actually talks to the database is a tool-module concern, exported under a known name:
export const datasources = {
Warehouse: async (sql, params, signal) => (await pool.query(sql, params)).rows,
};A DB driver is an npm package, so keeping it out here is what leaves the transpiler's zero-dependency promise intact. Without a bridge the program stops before running anything: datasource 'Warehouse' has no registered bridge.
The declarations themselves, their field defaults, the row-validation guarantee and the reasoning behind the absent transaction surface are on Data & Integration.
Sandbox policy
{
"tool_module_policy": {
"sandbox": "strict",
"allow_builtins": ["crypto"],
"allow_env": false,
"allow_network": false,
"max_memory_mb": 256,
"max_concurrency": 2
}
}| Setting | Values | Default | Effect |
|---|---|---|---|
sandbox | none · strict | none | strict: turns on the load-time checks below plus a source lint. CommonJS/TS modules additionally run inside a VM context with an explicit global list; ESM modules load in-process. |
allow_builtins | string list | [] | The Node builtins that are permitted. A require("fs") that is not on the list is rejected. |
allow_env | bool | false | When false, a module whose text names process or globalThis is refused. A lint, not a block — see the warning below. |
allow_network | bool | false | When false, a module whose text names fetch, http, net, tls or WebSocket is refused. A lint, not a block. |
max_memory_mb | int ≥ 16 | — | Accepted and recorded, but not enforced since v0.6.0 — it bounded the child process that no longer exists. |
max_concurrency | int ≥ 1 | 1 | Concurrent calls into a module. Was worker-bound; see the transpiler notes for how it is treated now. |
Read this before trusting strict with someone else's module. Until v0.6.0 an ESM module ran in a child process under the OS permission model; that runtime is gone and the isolation could not be reproduced, so it was removed rather than faked. What strict means now splits in two:
Enforced when the module loads, fail-closed: the builtin allowlist — including a computed specifier, which resolves to something not on the list and is refused; resolution confined to the project directory; manifest integrity; and, for CommonJS, a VM context whose globals are an explicit list rather than the real ones.
A lint over the module's text, nothing more: the allow_env, allow_network and dynamic-code checks. They match spellings. A module that names none of them still runs with the process's own privileges — this one passes every check and starts a process:
export const scan = () => ({ count: Bun.spawnSync(["id", "-u"]).stdout.toString().trim().length });That is measured, and it is locked by a passing test so the lint is never mistaken for a boundary later. The honest contract today: you choose which tool module to load, and the language does not confine it. Load only code you would run yourself. Tracked in ROADMAP.md § U10.
The defaults in the table above apply inside a tool_module_policy block. If the block is absent, none of them do: the module runs with sandbox: "none", and process, global and the network are all reachable. Verified by running a tool module with no policy — fetch is defined.
So allow_network: false is not the state you get by doing nothing; it is the state you get by declaring a policy and leaving the key out. A tool module you did not write should always be given a policy.
Always forbidden in strict mode
eval(...)andFunction(...)— dynamic codechild_process— process spawning- Loading modules from outside the project directory
Until v0.6.0 an ESM module in strict mode ran out of process under the OS permission model. That branch is gone with the runtime it depended on, and an ESM module now loads in-process, like any other import.
What still runs before it loads, for ESM as for the rest: the source lint, manifest integrity, the dependency allowlist and the builtin allowlist. What does not: the VM context with an explicit global list — that exists only for CommonJS and TypeScript modules. So an ESM module under strict sees the real globals.
If you want the VM context, write the module as CommonJS or TypeScript. If you do not, nothing about strict makes an ESM module safe to load blindly — see the contract above.
Security policies
tool_policy
| Value | Behavior |
|---|---|
allow default | No restriction. |
deny_write | Tools with side_effects: "write" are rejected → TOOL_PERMISSION_DENIED. |
approval | Write tools run only if they appear in LUMA_APPROVED_TOOLS. If the terminal is interactive, approval is prompted for; if not, the call is rejected. |
export LUMA_APPROVED_TOOLS="db_write,send_email"
scope and credential
tool db_write(table: string, value: string) -> DbResult {
side_effects: "write"
scope: "db:write"
credential: "DB_PASSWORD"
}
agent Writer {
tools: [db_write]
tool_scopes: ["db:write"]
tool_policy: "approval"
}- If the env variable declared with
credentialis missing, the tool is never executed:Tool 'db_write' is missing credential: DB_PASSWORD. - If
scopeis declared and it is not in the agent'stool_scopeslist:Tool 'db_write' scope access was denied: db:write. - If
tool_scopesis not given at all, no scope check is performed.
needs and tool_contexts
needs declares the dependencies the host is to supply to the tool. They never enter the prompt or the model; they are passed straight into the handler's third parameter.
schema Database {
user: string
port: int
}
tool db_write(table: string) -> DbResult {
needs db: Database
side_effects: "write"
}{
"tool_contexts": {
"db": {
"user": { "env": "DB_USER" },
"password": { "env": "DB_PASSWORD" },
"port": { "env": "DB_PORT", "parse": "integer" }
}
}
}parse value | Result |
|---|---|
string default | Raw text. |
json | JSON.parse — an error if it is invalid. |
number | Must be a finite number. |
integer | Must be a whole number. |
boolean | Only "true" / "false". |
On the agent side, tool_contexts: ["db"] narrows down which contexts may be used. If no list is given, all of them are free; an empty list allows none of them. Access outside the allowance produces TOOL_CONTEXT_INVALID.
Note: the file-based context provider is not supported yet; use env for secrets.
Retry, idempotency, cache
Retry and backoff
tool http_get(url: string) -> HttpResult {
error: ToolError
retries: 3
retry_backoff: "exponential"
retry_delay: 100
timeout: 3000
}retry_backoff | Wait durations (retry_delay: 100) |
|---|---|
none default | 0 ms — retries immediately. |
fixed | 100, 100, 100 … |
exponential | 100, 200, 400 … |
Only errors with retryable: true are retried. If a cancellation signal arrives, retrying stops.
Idempotency
tool db_write(request_id: string, value: string) -> DbResult {
side_effects: "write"
idempotent: true
idempotency_key: request_id
idempotency_ttl_ms: 300000
retries: 3
retry_backoff: "exponential"
}idempotency_keycan only be an input field path (request_idorinput.request_id); an expression cannot be written there.- Same key + same input → the handler runs once, and the second call returns the stored result.
- Same key + different input →
TOOL_IDEMPOTENCY_CONFLICT. - When the TTL expires the record is dropped and the tool runs again.
Persistent store
{
"idempotency_store": {
"backend": "file",
"path": ".luma-idempotency.json",
"encryption": "aes-256-gcm"
}
}export LUMA_IDEMPOTENCY_KEY="a-long-and-secure-key"
The default store is process-local ("memory"). The file store uses an exclusive lock, dead-process lock recovery, a versioned envelope and an atomic rename. Corrupted files are quarantined and a fresh one is created. Expired records are cleaned up at startup.
The store is safe for processes on the same machine; in a distributed setup (a multi-machine worker pool) it does not provide a shared transactional backend. Keep this limit in mind in production.
Cache and single-flight
tool calculate(a: float, b: float, operation: enum["add", "multiply"]) -> CalcResult {
side_effects: "none"
cache: true
}- The cache key is the tool name plus the input JSON; it lives for the lifetime of the process.
- Concurrent calls for the same key share a single execution (single-flight);
single_flight: trueshows up in telemetry. - Write tools and tools that use
needscannot be cached.
Circuit breaker
agent Resilient {
tools: [http_get]
tool_failure_limit: 3
}Tool failures are counted within a single agent method call. When the counter reaches tool_failure_limit, the error is converted into the TOOL_CIRCUIT_OPEN code and becomes retryable: false — so attempts against a broken dependency stop.
Tool execution order
Every tool call — direct or native, it makes no difference — goes through exactly this pipeline:
1 registry lookup TOOL_NOT_FOUND
2 credential check TOOL_PERMISSION_DENIED
3 scope allowlist TOOL_PERMISSION_DENIED
4 tool_policy: deny_write TOOL_PERMISSION_DENIED
5 tool_policy: approval TOOL_PERMISSION_DENIED
6 input validation TOOL_INPUT_INVALID
7 needs resolution + type check TOOL_CONTEXT_INVALID
8 cache / single-flight lookup
9 idempotency lookup TOOL_IDEMPOTENCY_CONFLICT
10 handler execution (timeout + AbortSignal)
└─ error → if retryable, back to 10 with retry/backoff TOOL_TIMEOUT
11 output validation TOOL_OUTPUT_INVALID
12 cache/idempotency write + telemetry
└─ if there was an error: failure counter++ → TOOL_CIRCUIT_OPENThe effective timeout is computed as min(tool.timeout ?? agent.tool_timeout, agent.tool_timeout_max). When the timeout expires, the AbortSignal handed to the handler fires — tying long-running work to that signal on the handler side matters.
TypeScript type generation
A tagged union as the tool's type
A tagged by union is a declared schema, so it may be a tool's parameter type or its return type. As an input it arrives already tagged — the runtime writes the discriminator before the handler sees it, so input.doc.kind is there to switch on. As an output the direction reverses: nothing writes the tag for you, and a handler that returns bare member fields is rejected.
schema Invoice {
invoice_number: string
}
schema Receipt {
reference: string
}
schema Document = Invoice | Receipt tagged by kind
tool classify(text: string) -> Document {
side_effects: "none"
}Error: [TOOL_OUTPUT_INVALID] classify.output.kind must identify a tagged union member.
The generated types carry that requirement rather than leaving it to be discovered at runtime — a union becomes a discriminated union in TypeScript, so a handler that forgets kind fails to compile:
export type Document =
(Invoice & { kind: "Invoice" }) | (Receipt & { kind: "Receipt" });luma luma/018_direct_tool.luma \ --generate-tool-types tools.generated.d.ts
When this flag is given the program is not run; only the type file is produced.
// Generated by Luma. Do not edit manually.
export type ToolHandler<I, O, N = Record<string, never>> =
(input: I, signal?: AbortSignal, needs?: N) => O | Promise<O>;
export interface CalcResult {
result: number;
}
export interface CalculateInput {
a: number;
b: number;
operation: "add" | "multiply";
}
export interface CalculateNeeds {}
export type CalculateHandler = ToolHandler<CalculateInput, CalcResult, CalculateNeeds>;
export interface GeneratedToolModule {
calculate: CalculateHandler;
}import type { GeneratedToolModule } from "./tools.generated";
export const tools: GeneratedToolModule = {
calculate: async (input, signal, needs) => ({
result: input.operation === "add" ? input.a + input.b : input.a * input.b,
}),
};The generated file contains every schema in the program, the <Name>Input / <Name>Needs / <Name>Handler types for each tool, and the combined GeneratedToolModule interface. @desc descriptions are carried over as JSDoc comments.
export const tools: GeneratedToolModule = {
calculate: (input) => ({
result: input.a + input.b,
}),
};export const tools = {
calculate: (input: any) => ({
// the build does not break when the schema changes
results: input.a + input.b,
}),
};