UTCP for Lua — native tool calling, multiple transports, and LLM-ready CodeMode.
lua-utcp is a Lua implementation of the Universal Tool Calling Protocol (UTCP). It enables Lua applications to discover tools from providers, maintain a canonical registry, and invoke them directly via their native transport, eliminating the need for wrapper servers or provider-specific adapters.
UTCP manual / provider
│
▼
┌─────────────┐
│ Registry │
└──────┬──────┘
│
canonical tool
│
▼
┌─────────────┐
│ Client │
└──────┬──────┘
│
native transport
│
▼
Tool server
- Native Tool Calling: Invoke tools through their native transport without introducing a wrapper protocol server.
- Canonical Registry: Tools have a stable, unified name and schema across all transports.
- Transport Independence: Supports HTTP, SSE, Streamable HTTP, TCP, UDP, CLI, Text, GraphQL, and MCP.
- CodeMode Ready: Enables LLMs to generate Lua code that exclusively calls registered UTCP tools.
- LLM Friendly: Compatible with OpenAI-compatible APIs, including OpenRouter via
lua-openai. - Minimal Lua API: Designed for embedding within applications and agents.
- Structured Errors: Provides programmatic handling for tool and transport failures.
- Lua 5.3 or 5.4
lua-socketlua-cjson(recommended) ordkjson- LuaRocks (optional, but recommended for installation)
luarocks install lua-utcp-1.4-1.rockspecgit clone https://github.com/universal-tool-calling-protocol/lua-utcp.git
cd lua-utcp
make testThis example demonstrates creating a client with an HTTP provider, discovering its manual, and calling a tool:
local utcp = require("utcp")
local client = utcp.new({
providers = {
{
name = "demo",
provider_type = "http",
url = "http://127.0.0.1:8080",
tools_url = "http://127.0.0.1:8080/manual"
}
}
})
assert(client:discover())
local result, err = client:call_tool("echo", {
message = "hello"
})
assert(result, err)
print(type(result) == "table" and result.message or result)The key benefit is that the application invokes the echo tool via the canonical UTCP registry, abstracting away the underlying transport mechanism.
| Transport | Status |
|---|---|
| HTTP | ✅ Implemented |
| SSE | ✅ Implemented |
| Streamable HTTP | ✅ Implemented |
| TCP | ✅ Implemented |
| UDP | ✅ Implemented |
| CLI | ✅ Implemented |
| Text | ✅ Implemented |
| GraphQL | ✅ Implemented |
| MCP JSON-RPC over HTTP | ✅ Implemented |
| gRPC | Extension point |
| WebRTC | Extension point |
| WebSocket | Extension point |
The core client and registry are transport-agnostic. New transports can be implemented and registered via lua/utcp/transports/init.lua.
You can register a manual without relying on remote discovery:
client:add_manual({
manual_version = "1.0",
utcp_version = "1.0",
tools = {
{
name = "echo",
description = "Echo a message",
inputs = {
type = "object",
properties = {
message = { type = "string" }
},
required = { "message" }
},
tool_call_template = {
call_template_type = "http",
url = "http://127.0.0.1:8080/echo",
http_method = "POST"
}
}
}
})This makes the tool accessible through the same canonical registry used for discovered providers.
Authentication blocks accept the additive metadata proposed in UTCP issue
#62.
ownership describes whether a credential is shared by the connector
("static", the default) or provisioned for each end user ("user"). OAuth2
blocks may also declare grant_type, which defaults to "client_credentials".
local client = utcp.new({
providers = {
{
name = "calendar",
transport = "http",
url = "https://api.example.com",
auth = {
auth_type = "oauth2",
ownership = "user",
grant_type = "authorization_code",
token = my_access_token,
},
},
},
})
local metadata = assert(client:auth_metadata("calendar.list_events"))
assert(metadata.ownership == "user")
assert(metadata.grant_type == "authorization_code")client:auth_metadata(name) resolves the effective auth block, so a tool-level
block overrides its provider's block. It returns nil when the tool has no
auth block. Credential acquisition, token refresh, persistence, and per-session
enablement intentionally remain application responsibilities.
Every built-in transport also exposes transport:auth_metadata() for code that
owns transport construction. HTTP, GraphQL, SSE, Streamable HTTP, and MCP apply
an available OAuth2 token as a bearer header. CLI, text, TCP, and UDP preserve
the same metadata but leave credential serialization to the command, file
access policy, or native wire protocol rather than inventing a transport format.
Set guard on the client to evaluate every client:call_tool(...) invocation
before tool lookup, discovery, or transport dispatch. The guard can be a
function or an object with evaluate(call). It receives the requested
tool_name, args, and client, and returns a string or table verdict.
local client = utcp.new({
guard = {
evaluate = function(_, call)
if call.tool_name == "delete_account" then
return { decision = "review", reason = "human approval required" }
end
return "allow"
end,
},
})The supported decisions are allow, deny, review, and error. Only
allow reaches the underlying HTTP, CLI, MCP, or other native transport, and
each allowed call_tool invocation dispatches once. The other decisions, an
invalid verdict, or an evaluator failure return a structured UTCP error and do
not dispatch a tool call.
A review decision requires an approve(call, review_verdict) method. It must
return allow before the tool is dispatched; without it, the client returns
guard_review_required and makes no transport call.
guard = {
evaluate = function(_, call)
return {decision = "review", reason = "human approval required"}
end,
approve = function(_, call, review)
-- Present review.reason to an authorized human here.
return {decision = "allow"}
end,
}For deliberately safe, client-owned tools, bypass_tools can be an exact
allowlist (an array or {[tool_name] = true} map). A bypassed tool skips guard
evaluation and dispatches normally; use this only for tools whose safety does
not depend on the Guard policy.
guard = {
bypass_tools = {"healthcheck", "local_status"},
evaluate = function(_, call)
return {decision = "deny", reason = "not approved"}
end,
}utcp.guards.hol_guard adapts HOL Guard's
side-effect-free hol-guard command test <command> --json classifier to the
client guard interface. It only classifies tool calls that can be represented
as a shell command; it does not replace HOL Guard's native agent harnesses or
approval center.
Install HOL Guard separately, then configure a command extractor. The adapter
fails closed for a missing executable, malformed output, unknown result, or an
unmapped tool call. Set unmapped_decision explicitly only when those calls
are protected elsewhere.
local utcp = require("utcp")
local client = utcp.new({
guard = utcp.guards.hol_guard.new({
command_for = function(call)
if call.tool_name == "shell" then
return call.args.command
end
end,
unmapped_decision = "deny",
approve = function(call, review)
-- Present review.reason to an authorized human here.
return {decision = "allow"}
end,
}),
})HOL Guard 3's classification.explicitly_benign result dispatches the call;
review or block statuses use the optional application-owned approve
callback or deny it. A non-benign no_match result is held for review rather
than treated as safe. By default the adapter reads args.command; use
command_for for a different tool schema, and executable to provide an
absolute HOL Guard path.
Streaming tools can be consumed incrementally:
client:call_tool_stream("events", {}, function(event)
print(event.event, event.data)
end)The SSE parser handles event, id, and multi-line data fields, decoding JSON payloads when possible.
CodeMode is the LLM-focused execution layer of lua-utcp. It provides a constrained Lua environment where models generate code that interacts with the canonical UTCP registry, rather than directly producing transport-specific calls.
local codemode = utcp.codemode.new(client)
local result = codemode.call_tool("echo", {
message = "hello"
})The CodeMode API exposes only canonical tool operations, preventing the LLM from generating invalid transport calls or accessing undefined tool endpoints.
Generated Lua programs can orchestrate multiple registered tools:
local execution = assert(codemode:call_tool_chain([[
local a = codemode.call_tool("calculator.add", { a = 10, b = 20 })
return a
]]))The execution flow is as follows:
LLM
│
│ generates Lua
▼
CodeMode
│
│ call_tool(name, args)
▼
Canonical UTCP registry
│
▼
Native transport
│
▼
Tool server
This separation is particularly beneficial for agent runtimes, allowing the LLM to focus on expressing computation while UTCP manages tool discovery and invocation.
lua-utcp includes examples demonstrating the integration of CodeMode with OpenAI-compatible LLM APIs via lua-openai.
Install the optional dependency and configure your API key:
luarocks install lua-openai
export OPENROUTER_API_KEY=sk-or-...Start the example HTTP tool server:
make server-httpRun the generated-CodeMode example:
make example-openrouter-codemodeOr execute the chat-session variant:
make example-openrouter-codemode-chatThe complete architecture for this integration is:
OpenRouter / lua-openai
│
│ generate Lua
▼
CodeMode sandbox
│
│ canonical tool call
▼
UTCP registry
│
▼
Native transport
│
▼
Tool server
The LLM receives the discovered UTCP tool catalog and is prompted to generate Lua CodeMode. The generated code invokes registered tools using codemode.call_tool(...) without direct access to transport objects.
Refer to the following examples:
examples/openrouter_codemode.luaexamples/openrouter_codemode_chat.lua
Providers can also be defined in JSON and loaded into the canonical registry:
local utcp = require("utcp")
local provider = assert(utcp.load_provider("examples/provider.json"))
local client = utcp.Client.new()
assert(client:add_provider(provider))
local codemode = utcp.codemode.new(client)
local execution = assert(codemode:call_tool_chain([[
return codemode.call_tool("calculator.add", {
a = 10,
b = 20
})
]]))Related examples:
examples/provider.jsonexamples/provider_flow.luaexamples/provider_codemode.lua
The implementation is structured into modular layers:
utcp
├── client # Discovery and invocation logic
├── registry # Canonical provider/tool index
├── transports # Native transport implementations
├── codemode # Constrained Lua execution API
├── json # JSON backend abstraction
└── errors # Structured error handling
utcp.client: Handles provider discovery, manual registration, and tool invocation.utcp.registry: Manages the indexing and lookup of providers and tools by name and tag.utcp.transports.*: Contains implementations for various native transports.utcp.codemode: Provides the Lua execution environment and canonical tool access for LLMs.utcp.json: Abstracts the underlying JSON library.utcp.errors: Defines the structure for error handling.
Network-related examples utilize local servers located in examples/servers/.
To start all demo servers:
make serversAlternatively, start individual servers using make server-* targets.
Run the unit and core test suite:
make testExecute transport integration tests:
make integrationlua-utcp/
├── lua/ # Library implementation
├── tests/ # Unit and transport tests
├── examples/ # Usage and CodeMode examples
├── examples/servers/ # Local demo tool servers
├── examples/provider.json # Example provider definition file
├── Makefile
└── lua-utcp-*.rockspec
MPL-2.0.