Cersei

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.

CallBackendPermission
io.read($file)Read toolread
io.write($file, content: $data)Write toolwrite
io.edit($file, ...)Edit toolwrite
io.glob($pattern)Glob toolread
io.grep($pattern)Grep toolread
io.delete($file)Bash (rm)write
net.get($url)WebFetch toolread
net.search($query)WebSearch toolread
agent.send(to: $id, $payload)Mailbox::publishwrite
agent.tools.call($name, {..})dynamic tool dispatchexec
agent.tools.list()registry/dispatch listnone
agent.tools.register(..)registry registerdangerous
agent.permission.ask('rw')permission policynone
kv.get($key) / kv.set($key, $v)KvStoreread / 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 PermissionRequest and consults the PermissionPolicy in the ToolContext. Under AllowReadOnly, io.write is denied with a line:col error and never reaches the tool layer.
  • Step budget. Limits::max_steps bounds 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 Sandbox handle is present in the context, io/net route 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()?;

On this page