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:
- JavaScript (
.js,.mjs,.cjs) — real JS via QuickJS. Recommended choice: full language and standard library (JSON,Math,String, regex, template literals,Array, …), familiar syntax. - Rhai (
.rhai) — a compact embeddable language for very small scripts. See §7.5.
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.
- Script tool (
kind: script_tool) — the LLM calls it like a regular tool; the script definesrun(args). - Script hook (
kind: script_hook) — intercepts the agent lifecycle.
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: "короткая причина" };
}
-
The second argument
configis optional: if the function declares onlyrun(args), it is not passed (backward compatibility). Ifrun(args, config)is declared, the entire tool YAML config is passed into it — this way settings can be specified directly in YAML (e.g.repos: [...]) and read in the script without creating an external file. -
content— a string that goes into the agent context. -
success— optional (defaulttrue). -
error— optional; if set andsuccessis not — the result is unsuccessful,contentis taken fromerror. -
visual— optional; a visual block or list of blocks (text/chart/table), shown to the user, but never goes into the LLM context. The script can keepcontentas a compact summary while returning the full data here:function run(args) { const page = args.page ?? 1; return { content: `страница ${page}`, success: true, visual: { type: "table", columns: [ { key: "id", title: "ID" } ], rows: [ { id: 1 }, { id: 2 } ], continuation: page < 3 ? { args: { page: page + 1 } } : undefined, }, }; }continuation.argsof blocktableenables lazy pagination (see common fields andPOST /v1/continuations). -
Returning a plain string is allowed →
contentwithsuccess=true. -
A thrown exception →
success=falsewith the error text.
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, // опционально
});
- Inference goes through the core provider; tokens are charged to the calling process (and its budget group).
- Respects the core concurrency limit.
- Budget exhausted →
caller budget exhaustederror. - LLM not connected → the call throws; wrap it in
try ... catch.
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-нормализованные векторы
- Requires
security.onnxinserver.yamland a server build with theonnxfeature; otherwise the calls throw. - The model is downloaded on first access (policy
download); raw data does not leave the perimeter. nerfor long texts automatically splits into windows and merges offsets.
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):
- a relative path (
"report.csv","sub/report.csv") is resolved fromtemp_dir; - an absolute path is allowed only inside
temp_dir; ..cannot escapetemp_dir; symlinks pointing outside are also rejected. Otherwise — errorfs_write: path ... outside the session temp dir.- Without a temp database (script outside a session),
fs_writethrowsno 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");
-
The result is a map
{ content, success, data, visual }(fieldsdata/visual—nullif the tool did not return them). -
visualis passed through as is: the script can forward a file to the user by returning it in itsvisual(e.g. afileblock fromoffice_tool):function run(args) { const r = call_tool("make_docx", { name: "report.docx", content: "# Отчёт" }); return { content: "готово", success: r.success, visual: r.visual }; } -
If the core did not pass the tool-call service (e.g.
agent-os hookwithout--live), the call throwscall_tool: no tool-call service attached.... -
A recursive call of the same
script_toolby itself leads to recursion — bounded bymax_operations; don't do this.
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
index— 0-based from the last call (0by default).- No result at that index — throws
get_tool_result: no result .... - Reads the history that the core maintains for composition (
ToolContext::tool_results); without history —no tool-result history attached....
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); // убрать завершённый процесс из таблицы
send/recv/waitblock the agent's turn — use them when the counterpart runs in another process/session, or poll viatry_recv/recvwith a timeout.- Without the IPC service — throws
ipc service not attached to this script call.
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 }
- The scope is that of the
session:<session_id>caller (resolved by the core, no id needed). - Without a session id → throws
memory: no session id...; without a message store →memory: no message store.... - This is read-only: the script does not write to the transcript (only the core/hooks write).
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
- No direct access to the core (
spawn,inject, …) — only HTTP/LLM/ state/DuckDB/shell/exec/fs_read/fs_write/fs_list/fs_read_bytes/fs_write_bytes-helpers,caller(), calling other tools viacall_tool(...)/list_tools()/get_tool_result(...), reading the transcript viamemory_*and return values. max_operationsinterrupts runaway loops (while(true){}produces an error rather than a hang).shell(...)is additionally bounded by a hard timeout.- State is not preserved between calls — each call gets a fresh
scope. For persistence, use the
state_*/db_*-helpers, HTTP/external services, or built-in hooks (faq_cache,memory). - Scripts are synchronous:
llm(...)blocks the worker thread for the duration of inference. Fine for short calls; avoid dozens of sequential LLM calls in a single script.
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:
- Objects are
#{ key: value }(not{ … });()is null. - String/array length is the property
.len(not.length). - String methods (
trim,split, …) mutate the string in place and return(): writes.trim();and reads, notlet t = s.trim();. - JSON —
parse_json/to_json(instead ofJSON.parse/JSON.stringify). max_operationsin Rhai is an exact operation counter (in JS it is a rough limit on QuickJS instructions).
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.