agent-block
Single-purpose agent building block with built-in mesh communication.
What is agent-block?
A headless agent runtime. Each agent runs as a single process, executes its task, then exits. No rich interactive TUI, no sub-agent orchestration — orchestration belongs to the caller (shell, A2A, CI, etc.).
agent-block handles the infrastructure that individual agents shouldn't have to — mesh connectivity (A2A), MCP server management, LLM API access — so that Lua code focuses purely on domain logic.
Think of it like Envoy for agents: the process itself is simple, but the communication layer is fully capable.
Design Decisions
- Single run — One process, one task, one exit. Orchestration belongs to the caller (shell, A2A, CI, etc.), not inside the agent
- Headless — No terminal UI. Agents are composed via A2A/mesh protocols, not interactive prompts
- Runtime owns the protocol — Mesh, MCP, and HTTP are provided by the runtime. Lua code never deals with connection management or wire formats
- Lua for logic, Rust for plumbing — Domain logic in Lua. VM, networking, and protocol handling in Rust
Architecture
┌─────────────────────────────────────────────┐
│ agent-block (binary) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
│ │ mlua-isle│ │ mesh-sdk │ │ llm-client│ │
│ │ (Lua VM) │ │ (relay) │ │ (API) │ │
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ ─────┴─────────────┴──────────────┴─────── │
│ Lua Stdlib Bridge │
│ mesh.send / mesh.on / llm.chat / fs.read │
│ tool.register / tool.call / log.* / env.* │
│ mcp.connect / mcp.call / mcp.list_tools │
└─────────────────────────────────────────────┘
↕ WebSocket ↕ stdio
┌─────────────────┐ ┌──────────────────┐
│ agent-mesh │ │ MCP Servers │
│ relay │ │ (outline-mcp) │
└─────────────────┘ └──────────────────┘
Usage
# Basic
# With project context
# With mesh
ANTHROPIC_API_KEY=...
MCP Echo Harness
A self-contained reference MCP server for smoke-testing the agent-block MCP client bridge. Exposes tools, resources, prompts, logging, and sampling over stdio or HTTP.
# stdio (default) — connect via mcp.connect("echo", "target/debug/examples/echo_mcp_server", {})
# HTTP on an ephemeral port — prints ECHO_MCP_URL=http://127.0.0.1:<port>/mcp
# Also emit 5 log notifications (1-second intervals) and attempt a sampling round-trip
Verify from Lua (requires the server to be running with --transport http):
local url = os.
mcp.
print -- 2 tools: echo, slow_echo
print -- 2 resources: text://hello, text://note
print -- 1 prompt: greet
-- call slow_echo to exercise progress notifications
mcp.
print
See examples/verify_echo_harness.lua for the full verification script.
Lua API
llm.*
llm.chat(messages, opts)— LLM call (Anthropic Messages API)
tool.*
tool.register(name, schema, handler)— Register a tooltool.call(name, input)— Call a registered tooltool.list()— List registered tool namestool.schema()— Anthropic tools-format schema array
mcp.*
mcp.connect(name, command, args)— Spawn MCP server over stdio + initialize handshakemcp.connect_http(name, url, opts)— Connect to an MCP server over HTTP transport.opts.transport = "sse" | "http"(default"http"= Streamable HTTP;"sse"= SSE).opts.headerstable is forwarded as request headers.mcp.call(name, tool_name, arguments)— Call an MCP toolmcp.list_tools(name)— List available toolsmcp.list_resources(name)— List resources exposed by the server. Returns{ ok=true, resources=[{uri, name, description, mimeType, ...}] }.mcp.read_resource(name, uri)— Read a resource by URI. Returns{ ok=true, contents=[{uri, mimeType, text|blob}] }.mcp.list_prompts(name)— List prompt templates exposed by the server. Returns{ ok=true, prompts=[{name, description, arguments}] }.mcp.get_prompt(name, prompt_name, args)— Retrieve a rendered prompt template. Returns{ ok=true, description, messages=[{role, content}] }.mcp.on_progress(name, handler)— Register a per-server progress notification callback.handler(token, progress, total, message)is called for eachnotifications/progressevent from the named server. Handler must be a pure Lua function.mcp.on_log(name, handler)— Register a per-server log notification callback.handler(level, logger, data)is called for eachnotifications/messageevent from the named server. When no handler is registered the notification is forwarded to the Rusttracingtarget"lua"at the corresponding level (debug/info/notice/warning/ error/critical/alert/emergency). Handler must be a pure Lua function.mcp.cancel(name, request_id)— Send anotifications/cancellednotification to the named server for the givenrequest_id. Also fired automatically whenmcp.calltimes out. Explicit use is only needed for manual cancellation flows.mcp.set_sampling_handler(server_name, handler)— Register a per-server Lua function to respond tosampling/createMessagerequests from the MCP server.handler(params)receives theCreateMessageRequesttable and must return a table matchingCreateMessageResult({ model, stop_reason, role, content }). When no handler is registered the server receivesmethod_not_found.mcp.server_info(name)— Return the server'sInitializeResultas a Lua table. Returns{ ok=true, server_info={serverInfo, capabilities, ...} }on success. Useful for inspecting which MCP capability groups (resources, prompts, tools, etc.) a server declares. Returns{ ok=false, error="..." }if the server is not connected.mcp.disconnect(name)— Disconnect server
mesh.*
mesh.send(agent_id, payload)— Synchronous send (raises Lua error on failure)mesh.request(agent_id, payload)— Request-responsemesh.agent_id()— Own AgentId
std.fs.* (mlua-batteries)
std.fs.read(path),std.fs.write(path, content),std.fs.glob(pattern),std.fs.exists(path)std.fs.walk(dir),std.fs.copy(src, dst),std.fs.mkdir(path),std.fs.remove(path)std.fs.is_file(path),std.fs.is_dir(path),std.fs.read_binary(path),std.fs.write_binary(path, bytes)
sh.*
sh.exec(cmd, opts)— Execute a shell command
std.json.* (mlua-batteries)
std.json.encode(value),std.json.decode(str),std.json.encode_pretty(value)
std.env.* (mlua-batteries + agent-block extensions)
std.env.get(key),std.env.set(key, value),std.env.get_or(key, default),std.env.home()std.env.agent_id(),std.env.project_root()— agent-block specific
std.path.* / std.time.* (mlua-batteries)
std.path.join(...),std.path.basename(path),std.path.dirname(path)std.time.now(),std.time.sleep(secs),std.time.measure(fn)
agent (StdPkg — require("agent"))
Built-in ReAct loop module. Available without any path configuration after cargo install.
local agent = require
local result = agent.
if result.
-- result fields: ok, content, usage{input_tokens,output_tokens,total_tokens}, num_turns, error, messages
Provider Switching
By default agent.run uses the Anthropic Messages API. Pass provider = "openai" to route to any OpenAI-compatible endpoint (vLLM, llama.cpp, OpenRouter, RunPod, etc.):
-- Anthropic (default) — requires ANTHROPIC_API_KEY
local result = agent.
-- OpenAI — requires OPENAI_API_KEY (or opts.api_key)
local result = agent.
-- Local vLLM / llama.cpp / RunPod — custom base_url
local result = agent.
Environment variables used per provider:
| provider | default key env | override via |
|---|---|---|
anthropic |
ANTHROPIC_API_KEY |
opts.api_key / opts.api_key_env |
openai |
OPENAI_API_KEY |
opts.api_key / opts.api_key_env |
opts.base_url overrides the endpoint root. Default for openai is https://api.openai.com/v1.
cache_control, context_management, and context_management_config are Anthropic-only: they are operative when provider="anthropic" (or unset) and emit a warn-level log message then are ignored when provider="openai".
Key behaviours:
- MCP servers listed in
mcp_serversare connected automatically and disconnected on exit (even on error). - Each entry may use the stdio form
{ name, command, args }or the HTTP form{ name, url, transport_opts }. Both forms can coexist in the same list. - Pass
sampling = fninagent.runopts to register a single Lua function as thesampling/createMessagehandler for every connected MCP server (mcp.set_sampling_handleris called per server automatically). - Pass
enable_resources = trueinagent.runopts to automatically register{server}__mcp_list_resourcesand{server}__mcp_read_resourceas LLM-callable tools for each connected server that declares theresourcescapability. Defaultfalse. If a server does not declareresources, the opt-in is silently skipped (logged atinfo). - Pass
enable_prompts = trueinagent.runopts to automatically register{server}__mcp_list_promptsand{server}__mcp_get_promptas LLM-callable tools for each connected server that declares thepromptscapability. Defaultfalse. Capability check and silent skip apply the same way asenable_resources. - Pass
on_progress = fn(ev)inagent.runopts to receive progress notifications from all connected MCP servers. The callback is called with an envelope table{ type="progress", server, token, progress, total, message }. No capability gate — all servers are registered. User callback errors are swallowed and logged atwarn. - Pass
progress_to_log = trueinagent.runopts to bridge progress notifications tolog.infoautomatically. Ignored whenon_progressis also set (callback takes priority). Defaultfalse. - Pass
on_log = fn(ev)inagent.runopts to receive log notifications from servers that declare theloggingcapability. The callback is called with an envelope table{ type="log", server, level, logger, data }. Servers without logging capability are silently skipped (logged atinfo). User callback errors are swallowed and logged atwarn. - Pass
log_to_stderr = trueinagent.runopts to bridge server log notifications tolog.debug|info|warn|errorautomatically. Ignored whenon_logis also set (callback takes priority). Logging capability gate applies the same way ason_log. Defaultfalse. - MCP tool names are namespaced as
server_name__tool_nameto avoid collisions. - Tool dispatch: MCP tools via
mcp.call(), registered Lua tools viatool.call(). - Never throws — all errors returned as
{ ok=false, error="..." }. - Context editing is on by default: once the conversation crosses ~80K input tokens, Anthropic evicts all but the most recent 3 tool-use / tool-result pairs server-side so the loop can keep running. Works on Sonnet 4 / Sonnet 4.5 / Haiku 4.5 / Opus 4 / 4.1 / 4.5. Pass
context_management = falseto disable, orcontext_management_config = { edits = { ... } }to replace the default entirely (the whole table is forwarded asbody.context_management; no partial merge). on_turn(info)gains an additiveinfo.context_managementfield that forwards the rawresponse.context_managementfrom Anthropic ({ applied_edits = { { type, cleared_tool_uses, cleared_input_tokens }, ... } }). The field is absent on turns where the server did not fire any edit — nil-guard before indexing.- The
blocks/directory is embedded in the binary; place a localblocks/agent/init.luain the project root to override. - LLM dump logging is safe-by-default and ENV-driven:
AGENT_BLOCK_LLM_DUMP=off|meta|full(defaultoff)- when unset,
RUST_LOGcontainingdebugortraceenablesmeta fullis downgraded tometawhenAGENT_BLOCK_ENV=prod|productionunlessAGENT_BLOCK_LLM_DUMP_ALLOW_PROD=true- request auth headers (
x-api-key/authorization) are always redacted in dump logs - log lines use fixed-order
key=valueformat with a unique marker (prefix=ab.obs component=llm); legacyprefix=ab.llmlines are also emitted for compatibility metaincludes call correlation and runtime signals (call,turn,iter,latency_ms,stop_reason,tool_uses, token usage, context edit count)- optional
agent.run({ log_meta = { trace_id, agent_id, agent_name, run_id } })appends external context to dump lines (same keys can also come fromAGENT_BLOCK_TRACE_ID,AGENT_BLOCK_AGENT_ID,AGENT_BLOCK_AGENT_NAME,AGENT_BLOCK_RUN_ID)
lshape (Vendored package — require("lshape"))
lshape is vendored under blocks/lshape/ so scripts can use schema validation
and LuaCATS generation without external installation.
local lshape = require
local T = lshape.
local User = T.
local ok, why = lshape..
assert
log.*
log.info/warn/error/debug(msg)
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.