AgentTemplate Language
AgentTemplate — a small functional DSL that LLMs author and the Cersei runtime executes. Namespaced builtins, method chaining, agent messaging, and a permission-gated interpreter.
AgentTemplate
AgentTemplate is a tiny functional language that agents and LLMs write, and the Cersei runtime executes on top of the existing tool set. It is the programmable surface of AgentRL: you can't easily make Cersei rewrite its own runtime, but an LLM can emit a short template program — chained file/network/agent operations — that the runtime runs safely and can then register as a reusable tool.
It ships as the cersei-agentlang crate, re-exported as cersei::agentlang when the agentlang (or agentrl) feature is on.
Design. Hand-written lexer + recursive-descent parser, zero parser dependencies, precise line:col spans for LLM self-correction. Values are serde_json::Value throughout — the same universal type the tool layer already speaks. Every side-effecting builtin is permission-gated, and a step budget bounds every run.
A taste
# variables start with $
$cfg = io.read("/etc/app.toml")
io.write("/tmp/app.toml", content: $cfg)
# method chaining — the previous result flows into the next call
io.read("/tmp/in.txt").write("/tmp/out.txt")
# the most important feature: agents talking to agents
$tools = agent.tools.list()
agent.send(to: "planner", $tools)
# ask for a capability; returns a bool you can branch on
agent.permission.ask('rw')Grammar
program = { NEWLINE } { statement ( NEWLINE | EOF ) } ;
statement = assignment | expr ;
assignment = VAR "=" expr ; (* $x = ... *)
expr = chain ;
chain = primary { "." IDENT "(" [args] ")" } ; (* .method(...) tails *)
primary = call | VAR | literal | "(" expr ")" ;
call = IDENT { "." IDENT } "(" [args] ")" ; (* io.read, agent.tools.call *)
args = arg { "," arg } [ "," ] ;
arg = IDENT ":" expr | expr ; (* named or positional *)
literal = STR | NUM | "true" | "false" | "null" | "[" [expr {"," expr}] "]" ;- Variables are
$name. Assignment is$name = expr. - Strings use
'single'or"double"quotes; escapes\n \t \\ \" \'. - Comments start with
#and run to end of line. - Statements are newline-terminated.
Builtins
Each builtin maps to a concrete Cersei tool or cross-sandbox primitive, and carries a permission level enforced before it runs.
| Call | Backend | Permission |
|---|---|---|
io.read($file) | Read tool | read |
io.write($file, content: $data) | Write tool | write |
io.edit($file, ...) | Edit tool | write |
io.glob($pattern) | Glob tool | read |
io.grep($pattern) | Grep tool | read |
io.delete($file) | Bash (rm) | write |
net.get($url) | WebFetch tool | read |
net.search($query) | WebSearch tool | read |
agent.send(to: $id, $payload) | Mailbox::publish | write |
agent.tools.call($name, {..}) | dynamic tool dispatch | exec |
agent.tools.list() | registry/dispatch list | none |
agent.tools.register(..) | registry register | dangerous |
agent.permission.ask('rw') | permission policy | none |
kv.get($key) / kv.set($key, $v) | KvStore | read / write |
agent.tools.register(..) requires the AgentRL ToolRegistry to be wired into the dispatch layer; without it, the call returns an explicit "unsupported in this context" error rather than failing silently.
Chaining semantics
In a chain like io.read($src).write($dst), the result of each call is threaded into the next as the implicit value $_ and as that tool's pipe-target input (e.g. Write's content) when you don't supply it explicitly. Chain tails inherit the head's namespace, so .write(...) after io.read(...) resolves to io.write.
Running a program
Two ways: directly via run_program, or through the RunAgentTemplate tool (which is how an agent runs it).
use cersei::agentlang::{run_program, EvalCtx, VecToolDispatch};
use cersei::prelude::*;
use std::sync::Arc;
// Dispatch resolves builtin tool names → real Tool implementations.
let dispatch = Arc::new(VecToolDispatch::new(vec![
Arc::new(cersei::tools::file_read::FileReadTool) as Arc<dyn Tool>,
Arc::new(cersei::tools::file_write::FileWriteTool) as Arc<dyn Tool>,
]));
let ctx = ToolContext { /* working_dir, session_id, permissions, ... */ ..base };
let mut ev = EvalCtx::new(&ctx, dispatch)
.with_var("src", serde_json::json!("/tmp/in.txt"))
.with_var("dst", serde_json::json!("/tmp/out.txt"));
let value = run_program(
"$data = io.read($src)\nio.write($dst, content: $data)",
&mut ev,
).await?;use cersei::agentlang::{DispatchHandle, RunAgentTemplateTool, VecToolDispatch};
use cersei::prelude::*;
use std::sync::Arc;
// Inject a dispatch handle (and optionally a Mailbox / KvStore) into the
// tool context extensions, then hand the agent the RunAgentTemplate tool.
let dispatch: Arc<dyn cersei::agentlang::ToolDispatch> =
Arc::new(VecToolDispatch::from_boxed(cersei::tools::coding()));
let ext = Extensions::default();
ext.insert(DispatchHandle(dispatch));
let agent = Agent::builder()
.provider(Anthropic::from_env()?)
.tool(RunAgentTemplateTool) // the single execution surface
.extensions(ext) // builtins reach tools through this
.build()?;The EvalCtx is the eval environment:
EvalCtx::new(&tool_context, dispatch)
.with_mailbox(mailbox) // Arc<Mailbox> → enables agent.send
.with_kv(kv) // Arc<KvStore> → enables kv.get/set
.with_self_id(id) // this agent's identity for mailbox `from`
.with_var("x", json!(1)) // seed a variable
.with_limits(Limits { max_steps: 1000 });Safety
- Permission-gated. Before any side-effecting builtin, the interpreter builds a
PermissionRequestand consults thePermissionPolicyin theToolContext. UnderAllowReadOnly,io.writeis denied with aline:colerror and never reaches the tool layer. - Step budget.
Limits::max_stepsbounds total builtin calls, guarding against runaway/recursive programs. - No host escape. There is no
eval/exec primitive — the only way to touch the host is through mapped, gated builtins. - Sandbox-aware. When a
Sandboxhandle is present in the context,io/netroute through it (fail-closed if routing isn't available).
Errors for LLMs
Parse and runtime errors carry spans and render as self-correctable messages:
parse error at 1:9: unterminated string literal
runtime error at 2:1: runtime error: io.write: permission denied (read-only mode)Through the RunAgentTemplate tool these come back as an error ToolResult, so the agent loop feeds them straight back to the model to fix.
Teaching the language to a model
cersei::agentlang::AGENTLANG_SPEC (and language_spec()) is a short, example-heavy spec string. Inject it into an agent's system prompt when you allow it to author sub-agents:
let agent = Agent::builder()
.provider(Anthropic::from_env()?)
.tool(RunAgentTemplateTool)
.append_system_prompt(cersei::agentlang::AGENTLANG_SPEC)
.build()?;AgentRL API
API reference for cersei-agentrl — Orchestrator, the AgentRlRunner trait, CerseiRunner, ExecutionGraph, ToolRegistry, DynamicTool, and the Verifier system.
AgentRL Cookbook
Runnable AgentRL recipes — a self-improving coding agent, forcing the recovery loop, custom verifiers and runners, and authoring tools from AgentTemplate programs.