assay-lua 0.10.4

General-purpose enhanced Lua runtime. Batteries-included scripting, automation, and web services.
Documentation
--- @module assay.openclaw
--- @description OpenClaw AI agent platform integration. Invoke tools, send messages, manage state, spawn sub-agents, approval gates, LLM tasks.
--- @keywords openclaw, clawd, agent, ai, workflow, invoke, state, diff, approve, llm, cron, spawn, message, notify
--- @quickref c.tools:invoke(tool, action, args) -> result | Invoke an OpenClaw tool action
--- @quickref c.messaging:send(channel, target, message) -> result | Send a message via channel
--- @quickref c.messaging:notify(target, message) -> result | Send notification to target
--- @quickref c.cron:add(job) -> result | Add a cron job
--- @quickref c.cron:list() -> [job] | List cron jobs
--- @quickref c.sessions:spawn(task, opts?) -> result | Spawn a sub-agent session
--- @quickref c.state:get(key) -> value|nil | Read state value by key
--- @quickref c.state:set(key, value) -> true | Write state value by key
--- @quickref c.state:diff(key, new_value) -> {changed, before, after} | Compare and store state
--- @quickref c.gates:approve(prompt, context?) -> bool | Request approval gate
--- @quickref c.llm:task(prompt, opts?) -> result | Execute an LLM task

local M = {}

function M.client(url, opts)
  opts = opts or {}

  -- Auto-discover URL from env vars
  if not url then
    url = env.get("OPENCLAW_URL") or env.get("CLAWD_URL")
    if not url then
      error("openclaw: url required (set $OPENCLAW_URL or $CLAWD_URL)")
    end
  end

  local base_url = url:gsub("/+$", "")

  local token = opts.token
  if not token then
    token = env.get("OPENCLAW_TOKEN") or env.get("CLAWD_TOKEN")
  end

  local state_dir = opts.state_dir
  if not state_dir then
    state_dir = env.get("ASSAY_STATE_DIR") or env.get("OPENCLAW_STATE_DIR")
    if not state_dir then
      local home = env.get("HOME") or "/tmp"
      state_dir = home .. "/.assay/state"
    end
  end

  -- Shared HTTP helpers (captured by all sub-object methods as upvalues)

  local function headers()
    local h = { ["Content-Type"] = "application/json" }
    if token then
      h["Authorization"] = "Bearer " .. token
    end
    return h
  end

  local function api_post(path_str, payload)
    local resp = http.post(base_url .. path_str, payload, { headers = headers() })
    if resp.status ~= 200 and resp.status ~= 201 then
      error("openclaw: POST " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
    end
    return json.parse(resp.body)
  end

  local function invoke(tool, action, args)
    return api_post("/tools/invoke", {
      tool = tool,
      action = action,
      args = args or {},
    })
  end

  -- ===== Client =====

  local c = {}

  -- ===== Tools =====

  c.tools = {}

  function c.tools:invoke(tool, action, args)
    return invoke(tool, action, args)
  end

  -- ===== Messaging =====

  c.messaging = {}

  function c.messaging:send(channel, target, message)
    return invoke("message", "send", {
      channel = channel,
      target = target,
      message = message,
    })
  end

  function c.messaging:notify(target, message)
    return invoke("message", "send", {
      target = target,
      message = message,
    })
  end

  -- ===== Cron =====

  c.cron = {}

  function c.cron:add(job)
    return invoke("cron", "add", { job = job })
  end

  function c.cron:list()
    return invoke("cron", "list", {})
  end

  -- ===== Sessions =====

  c.sessions = {}

  function c.sessions:spawn(task, spawn_opts)
    spawn_opts = spawn_opts or {}
    local args = { task = task }
    if spawn_opts.model then args.model = spawn_opts.model end
    if spawn_opts.timeout then args.timeout = spawn_opts.timeout end
    return invoke("sessions_spawn", "invoke", args)
  end

  -- ===== State =====

  c.state = {}

  function c.state:get(key)
    local path = state_dir .. "/" .. key .. ".json"
    if not fs.exists(path) then return nil end
    local content = fs.read(path)
    if not content or content == "" then return nil end
    return json.parse(content)
  end

  function c.state:set(key, value)
    local path = state_dir .. "/" .. key .. ".json"
    local dir = state_dir
    if not fs.exists(dir) then
      fs.mkdir(dir)
    end
    fs.write(path, json.encode(value))
    return true
  end

  function c.state:diff(key, new_value)
    local before = c.state:get(key)
    c.state:set(key, new_value)
    local changed = json.encode(before) ~= json.encode(new_value)
    return {
      changed = changed,
      before = before,
      after = new_value,
    }
  end

  -- ===== Gates =====

  c.gates = {}

  function c.gates:approve(prompt, context)
    local approval_result = env.get("ASSAY_APPROVAL_RESULT")
    if approval_result then
      return approval_result == "yes"
    end

    local mode = env.get("ASSAY_MODE")
    if mode == "tool" then
      error("__assay_approval_request__:" .. json.encode({
        prompt = prompt,
        context = context,
      }))
    end

    local interactive = env.get("OPENCLAW_INTERACTIVE") or env.get("TTY")
    if interactive then
      log.info("Approval required: " .. prompt)
      if context then
        log.info("Context: " .. json.encode(context))
      end
      return false
    end

    error("openclaw: approval_required: " .. json.encode({
      type = "approval_request",
      prompt = prompt,
      context = context,
    }))
  end

  -- ===== LLM =====

  c.llm = {}

  function c.llm:task(prompt, llm_opts)
    llm_opts = llm_opts or {}
    local args = { prompt = prompt }
    if llm_opts.model then args.model = llm_opts.model end
    if llm_opts.artifacts then args.artifacts = llm_opts.artifacts end
    if llm_opts.output_schema then args.output_schema = llm_opts.output_schema end
    if llm_opts.temperature then args.temperature = llm_opts.temperature end
    if llm_opts.max_output_tokens then args.max_output_tokens = llm_opts.max_output_tokens end
    return invoke("llm-task", "invoke", args)
  end

  return c
end

return M