Scripts (JavaScript and Rhai)

🔵 Tools and hooks can be written as scripts executed by a built-in engine and loaded from YAML — without rebuilding. Two engines, chosen by file extension:

Both engines provide the same contract and the same helper functions (see §7.3). A path script: on .js runs on QuickJS; any other extension — on Rhai.

Ready-made examples in config/scripts/: text_stats (Rhai and JS), currency (Rhai), wordcount (JS), audit and moderation (Rhai).


7.1 Script tool

config/scripts/tools/text_stats.yaml:

kind: script_tool
name: text_stats
description: "Считать символы, слова, предложения."
script: text_stats.js           # относительно этого YAML
parameters:
  type: object
  properties:
    text: { type: string, description: "Текст для анализа" }
  required: [text]
ui:
  icon: 📝
warnings:
  - failed: true
    message: "Не удалось проанализировать текст"

text_stats.js:

function run(args) {
    const text = (args.text ?? "").trim();
    if (text.length === 0) {
        return { content: "текст пуст", success: false, error: "empty text" };
    }
    const words = text.split(" ").filter((w) => w.length > 0).length;
    return { content: `Слов: ${words}`, success: true };
}

YAML fields

Field Required Description
kind yes script_tool
name no tool name (defaults to the file name)
description no shown to the LLM
parameters no JSON Schema of arguments
script yes path to .js/.rhai, relative to the YAML
ui no icon + call/result templates
max_operations no operation limit (default 10,000,000; 0 = no limit)
warnings no declarative warning rules

Contract run(args [, config])

function run(args, config) {
    // args — объект JSON-аргументов вызова
    // config — сырой YAML-конфиг инструмента (все поля, включая свои)
    return { content: "текст для LLM", success: true };
    // при ошибке:
    // return { content: "причина", success: false, error: "короткая причина" };
}

7.2 Script hook

config/scripts/hooks/audit.yaml:

kind: script_hook
name: audit
script: audit.js
events: [before_user_message, after_turn]   # опущено = все определённые функции

audit.js:

function before_user_message(ctx, msg) {
    return { notice: `🔍 audit: ${msg.content.length} символов` };
}

function after_turn(ctx, question, answer) {
    return { notice: `✅ audit: ${question.content.length} → ${answer.content.length} символов` };
}

YAML fields

Field Required Description
kind yes script_hook
name no hook name (defaults to the file name)
script yes path to .js/.rhai, relative to the YAML
events no which events to trigger on; empty = all defined functions
max_operations no operation limit
any custom fields no available in the script as ctx.config.<field>

Context ctx

Each function receives an object:

{
    pid: "...",            // PID процесса (строка UUID)
    agent: "...",          // имя процесса
    template: "...",       // имя шаблона (или null)
    session_id: "...",     // или null
    user_id: "...",        // или null
    config: {...}          // сырой YAML-конфиг, включая свои поля
}

Hook functions

function before_user_message(ctx, msg) {
    // msg: { role: "user", content: "..." }
    // возвращаемый объект — все поля опциональны:
    return {
        notice: "⚡ заметка в чат",                       // стримится, НЕ в контекст
        reply: "готовый ответ",                          // полный ответ, пропускает LLM
        suppress: true,                                  // отбросить сообщение
        inject: [{ role: "system", content: "подсказка" }], // доп. сообщения
        replace_message: { role: "user", content: "..." },
    };
}

function after_turn(ctx, question, answer) {
    // question / answer: { role, content } или null
    // возвращает { notice: "..." } или простую строку
}

function after_assistant_message(ctx, answer) {
    // answer: { role: "assistant", content } — финальный ответ
    // Output-guardrail: переписать/подавить ответ до сохранения.
    return {
        content: "[скрыто]",    // переписать текст на месте
        replace: { role: "assistant", content: "безопасный ответ" }, // заменить целиком
        suppress: false,        // не сохранять ответ
        notice: "ответ отредактирован", // заметка в чат-UI
    };
}

function after_loop(ctx, reason) {
    // reason: "job_done" | "terminated" | "blocked" | "budget_exhausted" | ...
    // возвращаемое значение игнорируется
}

function before_tool_call(ctx, tool, args) {
    // tool: имя инструмента; args: аргументы (JSON)
    return {
        arguments: { ... },                    // заменить аргументы
        deny: "не разрешено",                  // отклонить с причиной (failed-результат)
        result: { content: "из кэша", success: true }, // полностью подменить результат
        notice: "применена политика",          // заметка в чат-UI
    };
}

function after_tool_result(ctx, tool, result) {
    // result: { content, success, data, visual }
    return {
        content: "[скрыто]",                   // заменить текст для LLM
        success: true,
        data: { ... },
        visual: { ... },
        notice: "вырезаны персональные данные",
    };
}

function before_inference(ctx, request) {
    // request: { model, messages, tools, max_tokens, temperature }
    // Можно менять запрос и/или управлять вызовом:
    return { model: "gpt-4o-mini", temperature: 0.2 };
    // { deny: "причина" }  — отказать (ход блокируется)
    // { response: "..." }  — синтетический ответ без провайдера
}

function before_retry(ctx, attempt, error) {
    // Решение о ретрае транзиентной ошибки инференса.
    return { retry: true, delay_ms: 250 };
}

function before_syscall(ctx, syscall) {
    // syscall: сериализованный Syscall (как JSON), в т.ч. Send/Recv.
    return { deny: "межагентный трафик запрещён" }; // или {} для разрешения
}

function on_budget_threshold(ctx, level, spent, max, ratio) {
    // Бюджет слоя пересёк warn_at (или исчерпан): level = session|group|user.
}

function on_token(ctx, token, final_answer) {
    // token: стримимый чанк; final_answer: true — токены ответа
    // Output-only модерация/редакция: заменить или подавить токен для клиента.
    return { replace: "***", suppress: false };
}

function before_context_evict(ctx, evicted, summary) {
    // evicted: массив { role, content }, вытесненных из окна контекста
    // summary: текущее summary (или null) — вернуть кастомную компакцию
    return { summary: "сжатый старый контекст" };
}

function on_spawn(ctx) { }                 // после создания этого процесса
function on_terminate(ctx, exit_code) { }  // при завершении процесса
function on_error(ctx, message) { }        // при ошибке ядра у процесса
function on_session_created(ctx, session_id) { }  // сессия создана
function on_session_closed(ctx, session_id) { }   // сессия закрыта/истекла

A script error never breaks the agent's run.

A script hook sees the session scratch directory: a relative fs_write("x.txt", …) writes to caller().temp_dir (same as script_tool). For local testing via agent-os hook, set --session <id>.


7.3 Built-in helpers

HTTP

const r = http_get("https://api.example.com/data");
// r = { status: 200, body: "<body>", error: "" }
// сетевая ошибка → { status: 0, body: "", error: "..." } — не бросает

const r2 = http_post("https://api.example.com/submit", "payload");
const r3 = http_post(url, body, "application/json");

All HTTP methods with the same signature (url, body [, content_type [, timeout [, headers]]]):

const r = http_put("https://api.example.com/item/1", `{"title":"x"}`, "application/json");
const r = http_patch("https://api.example.com/item/1", `{"title":"y"}`);
const r = http_delete("https://api.example.com/item/1", "");

Or the generic form http(method, url, …):

const r = http("PATCH", url, `{"status":"closed"}`, "application/json");

Default timeout is 30s; overridden by an extra argument:

const r = http_get(url, 5);                       // таймаут 5с
const r = http_post(url, body, "application/json", 5);

Custom headers (object as the last argument — e.g. Referer). Options content_type / timeout / headers are optional and recognized by type (string / number / object), so they can be passed in any order:

const token = env_var("MY_API_TOKEN");   // "" если не задан (пишет предупреждение)
const r = http_get(url, 5, { Authorization: `Bearer ${token}` });
const r = http_post(url, body, "application/json", 5, { Authorization: `Bearer ${token}` });

LLM

const a = llm("Что такое фотосинтез?");                              // только user
const a = llm("Ты — краткий ассистент.", "Что такое фотосинтез?");   // system + user
const a = llm({
    user: "Вопрос",
    system: "Инструкция",     // опционально
    max_tokens: 500,          // опционально (по умолчанию 1024, максимум 4096)
    temperature: 0.3,         // опционально
});

Security. llm(...), called from a script, by default goes through connected security hooks guard/pseudonymize: surrogates/redaction go to the provider, and the response is expanded back for the script. Disable for a specific call — llm({ user: "...", safety: false }). Internal core calls (judge faq_cache, memory extraction, condense, summarization compact_results) do not pass through the membrane.

Structured output — llm_json({ ... output_schema }) returns a parsed JSON object. The core retries inference (up to 3 attempts) until the response validates against output_schema, mixing the error text into the next attempt:

const r = llm_json({
    user: "Извлеки имя и цену из текста",
    output_schema: {
        type: "object",
        properties: { name: { type: "string" }, price: { type: "number" } },
        required: ["name", "price"],
    },
});
// r = { name: "...", price: 123 }

If after all attempts the response is invalid — throws llm: structured output did not validate....

Local ONNX models

Scripts (JS and Rhai) can call the core's local ONNX service — NER, classification, embeddings:

const ents = ner("Иван Петров, a@b.com");      // [{ kind, start, end, value, score }]
const only = ner(text, ["person", "org"]);      // фильтр по классам

const labels = classify("topic-mini", text);    // [{ label, score }] (softmax/sigmoid)
const top = classify("topic-mini", text, 3);    // top-3

const vecs = embed("mini-emb", ["a", "b"]);     // L2-нормализованные векторы

Cron

Scripts can schedule agent runs (the contract is shared between JS and Rhai). These tasks are dynamic: persisted in logs/cron.db and survive restart:

const r = schedule_cron({
    name: "watch-price",
    template: "hello-agent",
    interval_secs: 3600,            // или schedule: "0 0 9 * * *", или oneshot_at
    prompt: "Проверь цену и сообщи.",
    notify: [{ type: "log" }],
});
// r = { name: "watch-price", ok: true }

const gone = cancel_cron("watch-price");   // -> true/false
const jobs = list_cron();                  // -> массив статусов заданий

The full field reference, sinks, and monitoring are in 11-cron-jobs. Without a connected cron service (e.g. agent-os hook without a server), calls throw.

State store

Scripts can read/write a scoped state store (scope is resolved from the caller, no id needed): state_get(scope, key), state_set(scope, key, value), state_delete(scope, key), state_keys(scope), state_push, state_range, state_sadd, state_smembers, state_srem. The semantics of the operations are the same as state_tool. Requires a connected logging.state_db_path.

DuckDB (SQL databases)

Scripts can read/write DuckDB databases via three helpers. Connections to a single file are pooled process-globally (one writer per file), temporal types (TIMESTAMP/DATE/TIME) are returned as ISO 8601 strings:

db_execute("data/dbs/{user}.db",
    "CREATE TABLE IF NOT EXISTS log (id BIGINT, text TEXT)");
db_execute("data/dbs/{user}.db", "INSERT INTO log VALUES (1, 'hi')");

const q = db_query("data/dbs/{user}.db", "SELECT * FROM log", 100);
// q = { columns: [...], rows: [...], count: N, has_more: bool }

const s = db_schema("data/dbs/{user}.db");          // таблицы + колонки текстом
const s = db_schema("data/dbs/{user}.db", "log");   // одна таблица
Function Description
db_query(path, sql [, limit]) SELECT query → { columns, rows, count, has_more } (default limit 100)
db_execute(path, sql) DDL/DML → number of affected rows (throws on error)
db_schema(path [, table]) introspection of tables/columns as text

path supports placeholders {user}, {session}, {template}/{agent}, resolved from the caller's context (as with duckdb_tool) — this way a script gets "its own" database per user without seeing others' ids.

Shell (CLI)

Scripts can run a command through the platform shell (cmd /C on Windows, sh -c elsewhere). The call never throws — errors are returned in the map:

const r = shell("git log --oneline -n 5");
// r = { ok: true, exit_code: 0, stdout: "...", stderr: "" }
// таймаут / несуществующая команда → { ok: false, exit_code: -1, stderr: "..." }

const r2 = shell("sleep 60", 5);   // жёсткий таймаут 5с (по умолч. 30с)

shell(...) goes through the shell (cmd /C / sh -c), so double quotes and %VAR% (on Windows) are interpreted by the shell. For paths with spaces, quotes inside arguments, and % in git --pretty=format:..., use exec(...) — it launches the process directly, without a shell, and arguments are passed verbatim:

const r = exec("git", ["-C", ".", "log", "--pretty=format:%H%x1f%an", "-n", "5"]);
const r2 = exec("git", ["log", "--author=Alice Bob"], 30);  // третий аргумент — таймаут

Files

fs_read(path) reads a UTF-8 text file, fs_write(path, content) writes it, fs_list(path) returns a list of directory entries (directories — with / at the end). All three throw on error:

fs_write("report.csv", "a,b\n1,2\n");   // относительный путь → в scratch сессии
const cfg = fs_read("data/git/repos.json");  // чтение — по путям сервера
const entries = fs_list(caller().temp_dir);  // ["report.csv", "sub/", ...]

Writing is restricted to the session scratch directory (caller().temp_dir):

Reading (fs_read/fs_list) remains read-only and unrestricted — it can be used to read server data (data/..., configs).

Binary files — fs_read_bytes(path) returns the contents as base64, fs_write_bytes(path, b64) writes the bytes (base64) back. Writing is likewise restricted to the session scratch directory:

const b64 = fs_read_bytes("photo.png");          // base64
fs_write_bytes("copy.png", b64);                  // → temp_dir/copy.png

Caller context (caller)

caller() returns the identity of the process on whose behalf the script is executing (the asymmetry with hooks has been eliminated — script_tool also sees "who I am"):

const me = caller();
// { pid, session_id, user_id, template, temp_dir }  — null, если неизвестно
fs_write(`${me.temp_dir}/notes.txt`, `user=${me.user_id}`);

Calling other tools (call_tool)

A script can call any registered tool by name — the core dispatches it exactly as it would a call from an LLM (access check, full context, panic protection, result truncation, budget accounting, and recording to the result history):

const r = call_tool("make_docx", { name: "report.docx", content: "# Отчёт" });
// r = { content: "...", success: true, data: ..., visual: ... }

const filled = call_tool("fill_contract", { name: "dogovor.docx", data: { client: "ООО Ромашка" } });

For side-effecting tools, an idempotency key can be passed as the third argument — a repeated call with the same key within the TTL window will return the saved result without executing the tool (see idempotency: in 05-tools-core):

const paid = call_tool("pay", { amount: 100 }, "invoice-42");

List of available tools (list_tools)

list_tools() returns the schemas of tools available to the calling process (the same set that the LLM sees during function calling):

const tools = list_tools();
// [{ name: "make_docx", description: "...", parameters: { ... } }, ...]

Result of a previous tool call (get_tool_result)

get_tool_result(tool [, index]) reads the result of a tool already executed in this session (without re-running it) — composition without extra calls:

const r = get_tool_result("make_docx");        // последний результат
const prev = get_tool_result("make_docx", 1);  // предпоследний
// { content, success, data, visual }  — как у call_tool

Launching a subagent (run_subagent)

A script can delegate a task to a child agent (spawned as a child of the caller, spends its budget group, one-shot — cleaned up afterward):

const r = run_subagent({
    task: "Проанализируй отзыв и дай оценку",
    template: "review-agent",        // или instructions: "..."
    // name, tools, output_schema — опционально
});
// r = { pid, answer, data }  — data заполнен, если задан output_schema

Without a connected subagent service — throws run_subagent: no subagent service....

Inter-process communication (send / recv / wait / reap)

A script can coordinate with other agent processes — exchange messages and wait for processes to finish (available when the core has connected the IPC service; in the standard server — always):

send(child_pid, "собери данные");          // отправить текст другому процессу

let msg = try_recv(my_pid);                // неблокирующий приём: map или null
let msg = recv(my_pid);                    // блокирующий приём
let msg = recv(my_pid, 5000);              // приём с таймаутом (мс): null если пусто
// msg = { from, to, kind, text|value|… }   (kind: text | json | signal | …)

let r = wait(child_pid);                   // дождаться завершения процесса
// r = { pid, exit_code, completion_reason, completion_data }

reap(child_pid);                           // убрать завершённый процесс из таблицы

Session transcript (memory_*)

The script can read its own session transcript (what has already been saved in the message store — user/agent messages, tool-call, hooks, warning, error):

const recent = memory_history(20);        // последние 20 сообщений, по порядку
const hits   = memory_search("поступление", 5);  // поиск по словам, по релевантности
const n      = memory_count();
// каждая запись: { id, role, kind, content, meta, created_at, score }

Other

Function Description
now() current UTC time (RFC 3339 string)
sleep_ms(ms) blocking pause (capped at 60s) — a delay/backoff primitive
env_var(name) environment variable ("" if absent; logs a warning)
print(...), debug(...) log to the server
urlencode(s) / url_encode(s) percent-encoding UTF-8
urldecode(s) / url_decode(s) percent-decoding
base64_encode(s) / base64_decode(s) base64 UTF-8 (decode throws on invalid input)
ord(c), chr(i) character ↔ codepoint
matches(s, regex), replace_re(s, regex, repl) regex match / replace
extract_script_json(html, contains) first JSON blob <script> (e.g. __NEXT_DATA__) → object, or null
json_get(data, pointer) resolve a JSON pointer (/a/b/0, escapes ~0/~1) → value, or null

For JSON in JS, use the native JSON.parse / JSON.stringify.


7.4 Limits and guarantees


7.5 Rhai — an alternative language

For small scripts you can use Rhai (.rhai). The contract is identical, only the syntax differs:

fn run(args) {
    let text = args.text ?? "";
    text.trim();                      // trim() меняет на месте и возвращает ()
    if text.len == 0 {                // длина — свойство `.len`
        return #{ content: "текст пуст", success: false, error: "empty text" };
    }
    let words = text.split(" ").filter(|w| w.len > 0).len();
    #{ content: `Слов: ${words}`, success: true }
}

Differences from JS:


7.6 Debugging

agent-os hook config/scripts/hooks/audit.yaml user "Привет, мир"
agent-os hook config/scripts/hooks/moderation.yaml user "Привет, мир" --live

Script tools are tested with tests in the agent-os tool style or in a live chat with the agent to which the tool is attached.