Luma public docs
Türkçe

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

luma
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
}
PartRule
ParametersMust be typed. Field annotations can be used (@desc, @range, @default…). An input schema is generated from them automatically.
Return typeMust be a reference to a declared schema — including a tagged by union, see below. A primitive or an inline type is not accepted.
BodyConfiguration keys only. It contains no code — it may be left empty.
Optional parameterlimit?: int @default(10) — filled in when absent, and not listed as required in JSON Schema.
The input schema is generated automatically

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

KeyValuesDefaultDescription
side_effectsnone · read · writenoneThe tool's side-effect class. tool_policy is built on top of it.
cachetrue · falsefalseKeeps the result for the same input inside the process.
scopestring—Permission label. It must appear in the agent's tool_scopes list.
credentialENV name—If this environment variable is missing, the tool is never executed.
errorschema name—The typed error schema that the error thrown by the handler is validated against.
retriesint0Number of extra attempts (only on retryable errors).
retry_backoffnone · fixed · exponentialnoneThe waiting strategy between attempts.
retry_delayms100The base wait duration.
timeoutms—A tool-specific timeout; it overrides the agent's tool_timeout.
idempotenttrue · falsefalseRun only once for the same key.
idempotency_keyinput field path—Mandatory with idempotent: true. E.g. request_id.
idempotency_ttl_msms300000How 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

RuleIf violated
A write tool cannot be cachedWrite tool 'x' cannot use cache.
A write tool must be idempotent to be retriedA write tool 'x' must be idempotent and specify an idempotency_key to be retried.
idempotent requires a keyIdempotent tool 'x' must specify an idempotency_key.
timeout: 0 is invalidTool 'x' timeout must be greater than zero.
A tool that uses needs cannot be cachedCache 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.

luma
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
  }
}
FormMeaning
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 tools list; otherwise Agent '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_INVALID code.
  • 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.

luma
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 validation
While tools are declared, the first request carries no strict schema

So 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.

Forcing one specific tool

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 familyModel → tool callRuntime → result
OpenAI-compatible · Azuretool_calls[]role: "tool" message
Anthropictool_use blockuser message containing tool_result
GeminifunctionCall partfunctionResponse part

This conversion is performed automatically in the adapter layer; nothing changes on the .luma side.

Limits

SettingScope
tool_rate_limit 5The total number of native tool calls within one ask flow. It resets on every new ask (and on every retry attempt).
tool_concurrencyHow many tools run concurrently within a single provider turn.
tool_failure_limitAfter 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

ts
export type ToolHandler = (
  input: any,                      // validated input
  signal?: AbortSignal,            // timeout / cancellation signal
  needs?: Record<string, any>,     // dependencies declared in the contract
) => any | Promise<any>;
tstools.ts
// 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

luma
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
}
ts
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 as retries allows.
  • 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/catch variable 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
jsonluma.config.json
{
  "tool_modules": [
    {
      "path": "./tools.ts",
      "version": "1.0.0",
      "integrity": "sha256-<base64-sha256>",
      "dependencies": ["node-fetch"],
      "dependency_root": "./vendor"
    }
  ]
}
Manifest fieldMeaning
pathThe module file (relative to the program directory).
versionSemver metadata. Loading the same file a second time with different metadata is an error.
integritysha256-<base64>. If the file contents do not match, loading is rejected.
dependenciesThe allowlist of packages the module may import/require. If a list is given, dynamic import() is forbidden.
dependency_rootThe filesystem root the dependencies may be read from (it cannot leave the project).

The short form is valid too: "tool_modules": ["./tools.ts"]

File typeLoading
.ts · .mtsTranspiled with typescript and then loaded (the runtime dependency is required).
.js · .cjsLoaded 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.

ts
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:

ts
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

jsonluma.config.json
{
  "tool_module_policy": {
    "sandbox": "strict",
    "allow_builtins": ["crypto"],
    "allow_env": false,
    "allow_network": false,
    "max_memory_mb": 256,
    "max_concurrency": 2
  }
}
SettingValuesDefaultEffect
sandboxnone · strictnonestrict: 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_builtinsstring list[]The Node builtins that are permitted. A require("fs") that is not on the list is rejected.
allow_envboolfalseWhen false, a module whose text names process or globalThis is refused. A lint, not a block — see the warning below.
allow_networkboolfalseWhen false, a module whose text names fetch, http, net, tls or WebSocket is refused. A lint, not a block.
max_memory_mbint ≥ 16—Accepted and recorded, but not enforced since v0.6.0 — it bounded the child process that no longer exists.
max_concurrencyint ≥ 11Concurrent calls into a module. Was worker-bound; see the transpiler notes for how it is treated now.
strict is a contract check, not an isolation boundary

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.

Without a policy there is no sandbox at all

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(...) and Function(...) — dynamic code
  • child_process — process spawning
  • Loading modules from outside the project directory
Under strict, an ESM module is checked less than a CommonJS one

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

ValueBehavior
allow defaultNo restriction.
deny_writeTools with side_effects: "write" are rejected → TOOL_PERMISSION_DENIED.
approvalWrite 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

luma
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 credential is missing, the tool is never executed: Tool 'db_write' is missing credential: DB_PASSWORD.
  • If scope is declared and it is not in the agent's tool_scopes list: Tool 'db_write' scope access was denied: db:write.
  • If tool_scopes is 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.

luma
schema Database {
  user: string
  port: int
}

tool db_write(table: string) -> DbResult {
  needs db: Database
  side_effects: "write"
}
jsonluma.config.json
{
  "tool_contexts": {
    "db": {
      "user":     { "env": "DB_USER" },
      "password": { "env": "DB_PASSWORD" },
      "port":     { "env": "DB_PORT", "parse": "integer" }
    }
  }
}
parse valueResult
string defaultRaw text.
jsonJSON.parse — an error if it is invalid.
numberMust be a finite number.
integerMust be a whole number.
booleanOnly "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

luma
tool http_get(url: string) -> HttpResult {
  error: ToolError
  retries: 3
  retry_backoff: "exponential"
  retry_delay: 100
  timeout: 3000
}
retry_backoffWait durations (retry_delay: 100)
none default0 ms — retries immediately.
fixed100, 100, 100 …
exponential100, 200, 400 …

Only errors with retryable: true are retried. If a cancellation signal arrives, retrying stops.

Idempotency

luma
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_key can only be an input field path (request_id or input.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

jsonluma.config.json
{
  "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 file store is not an external transactional backend

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

luma
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: true shows up in telemetry.
  • Write tools and tools that use needs cannot be cached.

Circuit breaker

luma
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_OPEN
How the timeout is computed

The 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.

luma
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:

ts
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.

tstools.generated.d.ts
// 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;
}
tstools.ts
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.

✓ Type safe
export const tools: GeneratedToolModule = {
  calculate: (input) => ({
    result: input.a + input.b,
  }),
};
✗ Type leak
export const tools = {
  calculate: (input: any) => ({
    // the build does not break when the schema changes
    results: input.a + input.b,
  }),
};