Home Docs Blog Demo

Lua Tools

Define custom MCP tools in Lua that AI agents can discover and call at runtime.

While connectors read data into the knowledge base, tools let agents act on that data. Lua tool extensions define custom MCP tools that AI agents (Cursor, Claude, browser LLMs) can discover via GET /tools/list and invoke via POST /tools/{name} — without recompiling Rust.

Connectors vs. Tools

Connector (read)Tool (write/act)
JiraIngest issues → searchCreate/update tickets
SlackIngest threads → searchPost messages
GitHubIngest issues/PRs → searchCreate issues, post comments
DeployTrigger deploys, run health checks
DocsGenerate summaries, create ADRs

Simple example: Echo tool

Start with the simplest possible tool to understand the contract:

tool = {
    name = "echo",
    version = "0.1.0",
    description = "Echoes back the input message and context info",
    parameters = {
        {
            name = "message",
            type = "string",
            required = true,
            description = "The message to echo",
        },
    },
}

function tool.execute(params, context)
    log.info("Echo: " .. params.message)

    -- Use the context bridge to access the knowledge base
    local sources = context.sources()

    return {
        echo = "Echo: " .. params.message,
        source_count = #sources,
    }
end
[tools.script.echo]
path = "tools/echo.lua"
timeout = 5

RAG-enriched Jira ticket creation

This tool searches the knowledge base for related context, then creates a Jira ticket with that context included:

tool = {
    name = "create_jira_ticket",
    version = "0.1.0",
    description = "Create a Jira ticket enriched with related context from the knowledge base",
    parameters = {
        {
            name = "title",
            type = "string",
            required = true,
            description = "Ticket title / summary",
        },
        {
            name = "description",
            type = "string",
            required = false,
            description = "Additional description text",
        },
        {
            name = "priority",
            type = "string",
            required = false,
            default = "Medium",
            enum = { "Low", "Medium", "High", "Critical" },
            description = "Ticket priority level",
        },
    },
}

function tool.execute(params, context)
    -- 1. Search the knowledge base for related docs
    local results = context.search(params.title, { limit = 5, mode = "hybrid" })

    -- 2. Build enriched description with related context
    local desc = params.description or ""
    if #results > 0 then
        desc = desc .. "\n\n## Related Context\n\n"
        for _, r in ipairs(results) do
            desc = desc .. "- **" .. r.title .. "** (score: " ..
                   string.format("%.2f", r.score) .. ")\n"
            desc = desc .. "  " .. (r.snippet or "") .. "\n"
            if r.source_url then
                desc = desc .. "  [View source](" .. r.source_url .. ")\n"
            end
        end
    end

    -- 3. Create the Jira ticket via API
    local payload = json.encode({
        fields = {
            project = { key = context.config.jira_project },
            summary = params.title,
            description = desc,
            priority = { name = params.priority },
            issuetype = { name = "Task" },
        },
    })

    local resp = http.post(
        context.config.jira_url .. "/rest/api/3/issue",
        payload,
        {
            headers = {
                ["Authorization"] = "Basic " .. base64.encode(
                    "user:" .. context.config.jira_token
                ),
                ["Content-Type"] = "application/json",
            },
        }
    )

    local result = json.decode(resp.body)
    return {
        key = result.key,
        url = context.config.jira_url .. "/browse/" .. result.key,
        related_docs = #results,
        status = "created",
    }
end
[tools.script.create_jira_ticket]
path = "tools/create-jira-ticket.lua"
timeout = 30
jira_url = "https://mycompany.atlassian.net"
jira_project = "ENG"
jira_token = "${JIRA_API_TOKEN}"

Post to Slack with context

tool = {
    name = "post_slack",
    version = "0.1.0",
    description = "Post a message to a Slack channel with relevant context from the knowledge base",
    parameters = {
        {
            name = "channel",
            type = "string",
            required = true,
            description = "Slack channel ID (e.g., C01ABCDEF)",
        },
        {
            name = "message",
            type = "string",
            required = true,
            description = "Message text to post",
        },
        {
            name = "include_context",
            type = "boolean",
            required = false,
            default = true,
            description = "Include related docs from the knowledge base",
        },
    },
}

function tool.execute(params, context)
    local text = params.message

    -- Optionally enrich with context
    if params.include_context then
        local results = context.search(params.message, { limit = 3, mode = "hybrid" })
        if #results > 0 then
            text = text .. "\n\n📚 *Related docs:*"
            for _, r in ipairs(results) do
                if r.source_url then
                    text = text .. "\n• <" .. r.source_url .. "|" .. r.title .. ">"
                else
                    text = text .. "\n" .. r.title
                end
            end
        end
    end

    local resp = http.post(
        "https://slack.com/api/chat.postMessage",
        json.encode({
            channel = params.channel,
            text = text,
        }),
        {
            headers = {
                ["Authorization"] = "Bearer " .. context.config.slack_token,
                ["Content-Type"] = "application/json",
            },
        }
    )

    local data = json.decode(resp.body)
    return {
        ok = data.ok,
        ts = data.ts,
        channel = params.channel,
    }
end

Create a GitHub issue

tool = {
    name = "create_github_issue",
    version = "0.1.0",
    description = "Create a GitHub issue with RAG-enriched context",
    parameters = {
        {
            name = "title",
            type = "string",
            required = true,
            description = "Issue title",
        },
        {
            name = "body",
            type = "string",
            required = false,
            description = "Issue body text",
        },
        {
            name = "labels",
            type = "string",
            required = false,
            description = "Comma-separated labels (e.g., 'bug,high-priority')",
        },
    },
}

function tool.execute(params, context)
    -- Search for related context
    local results = context.search(params.title, { limit = 3, mode = "hybrid" })

    local body = params.body or ""
    if #results > 0 then
        body = body .. "\n\n---\n\n### Related Context (auto-generated)\n\n"
        for _, r in ipairs(results) do
            body = body .. "- [" .. r.title .. "](" .. (r.source_url or "") .. ") (score: "
                .. string.format("%.2f", r.score) .. ")\n"
        end
    end

    -- Parse labels
    local labels = {}
    if params.labels then
        for label in params.labels:gmatch("[^,]+") do
            table.insert(labels, label:match("^%s*(.-)%s*$"))
        end
    end

    local resp = http.post(
        string.format("https://api.github.com/repos/%s/%s/issues",
            context.config.owner, context.config.repo),
        json.encode({
            title = params.title,
            body = body,
            labels = labels,
        }),
        {
            headers = {
                ["Authorization"] = "Bearer " .. context.config.github_token,
                ["Content-Type"] = "application/json",
                ["Accept"] = "application/vnd.github.v3+json",
            },
        }
    )

    local issue = json.decode(resp.body)
    return {
        number = issue.number,
        url = issue.html_url,
        related_docs = #results,
    }
end

Parameter schema

Each parameter supports:

FieldTypeRequiredDescription
namestringyesParameter name
typestringyes"string", "number", "boolean", or "integer"
requiredbooleannoWhether the agent must provide it
descriptionstringnoShown to the agent for better tool use
defaultanynoUsed if the agent doesn’t provide a value
enumarraynoRestrict to specific allowed values

Parameters are converted to OpenAI function-calling JSON Schema format, making them compatible with any agent that supports function calling.

Context bridge

The context argument in tool.execute(params, context) provides access to the Context Harness knowledge base:

context.search(query, opts?) — Search the knowledge base.

local results = context.search("auth flow", {
    mode = "hybrid",   -- "keyword" | "semantic" | "hybrid"
    limit = 5,
    source = "git",    -- Filter by source name
})
-- Returns: [{title, score, snippet, source_url, source, source_id}, ...]

context.get(id) — Retrieve a full document by UUID.

local doc = context.get("a1b2c3d4-...")
-- Returns: {id, title, body, source, source_id, source_url, updated_at}

context.sources() — List all configured data sources and their status.

local sources = context.sources()
-- Returns: [{source, document_count, chunk_count}, ...]

context.config — Tool-specific config from ctx.toml (env vars already expanded).

local api_key = context.config.api_key
local project = context.config.project_id

HTTP endpoints

GET /tools/list — Discover all registered tools with their schemas:

$ curl -s localhost:7331/tools/list | jq '.tools[] | {name, description, builtin}'
{"name": "search", "description": "Search indexed documents", "builtin": true}
{"name": "get", "description": "Retrieve full document by ID", "builtin": true}
{"name": "sources", "description": "List configured data sources", "builtin": true}
{"name": "create_jira_ticket", "description": "Create a Jira ticket", "builtin": false}

POST /tools/{name} — Invoke a tool:

$ curl -X POST localhost:7331/tools/create_jira_ticket \
    -H "Content-Type: application/json" \
    -d '{"title": "Fix auth token refresh", "priority": "High"}'

{
  "result": {
    "key": "ENG-1234",
    "url": "https://mycompany.atlassian.net/browse/ENG-1234",
    "related_docs": 3,
    "status": "created"
  }
}
StatusMeaning
200Success — {"result": {...}}
400Parameter validation failed
404Unknown tool name
408Lua script timed out
500Script execution error

CLI commands

# Scaffold a new tool:
$ ctx tool init post-slack
Created tools/post-slack.lua

# Test with sample params:
$ ctx tool test tools/echo.lua --param message="hello world"
Tool: echo v0.1.0
  Description: Echoes back the input message
  Parameters: message (string, required)
Result:
{
  "echo": "Echo: hello world",
  "source_count": 2
}

# Test with config from ctx.toml:
$ ctx tool test tools/create-jira-ticket.lua \
    --param title="Fix bug" \
    --param priority="High" \
    --source create_jira_ticket

# List all configured tools:
$ ctx tool list
Built-in tools:
  search           Search indexed documents
  get              Retrieve full document by ID
  sources          List configured data sources

Lua tools:
  echo             Echoes back the input message
  create_jira_ticket  Create a Jira ticket enriched with context
  post_slack       Post a message to Slack