← Writing a plugin
Extend kcode
Send a notification to your phone
Let the agent send a requested update to your ntfy topic. The Lua tool validates the message, calls curl through the session shell, and reports server acceptance, not proof of phone delivery.
The manifest supplies endpoint and topic settings. The Lua file registers send_notification and safely passes literal message text to curl. A kcode Lua-runtime test verifies the POST against a local HTTP receiver. No public notification or phone delivery is assumed.
ntfy-notify/README.md
# Send a remote notification
This Lua plugin adds `send_notification`, a tool for sending a requested update to an ntfy topic. It runs curl through kcode's session shell and normal permission checks. There is no watcher or background daemon.
## Install and configure
Copy `plugin.toml` and `main.lua` into `<project>/.kcode/plugins/ntfy-notify/`. Requires curl. Configure the server and topic in your kcode configuration:
```toml
[plugins.ntfy-notify]
endpoint = "https://ntfy.sh"
topic = "your-chosen-topic"
```
Subscribe to that topic in your ntfy client. No topic is configured by default, so accidental calls cannot send to a shared example topic. Public ntfy topics are not private: do not send secrets or confidential project details. This small example does not implement authentication; use an appropriate trusted endpoint and do not put credentials into the URL.
```json
{"title":"Build finished","message":"The requested checks passed"}
```
The tool validates the title, topic and message size, sends a POST, and checks HTTP acceptance. It returns `accepted`, `http_status`, and `delivery_confirmed: false`. HTTP acceptance is not proof of delivery to a phone. Requests time out after 20 seconds and are not automatically retried; inspect failures before retrying because a timed-out request may already have arrived.
## How the files cooperate
`plugin.toml` declares the shell-tool capability and typed server/topic settings. `main.lua` registers the tool, validates input, safely quotes shell arguments, and pipes literal message bytes to curl. Sending text through stdin avoids treating an `@` prefix as a local filename.
## Verification
The kcode repository contains a local-only integration test. It loads this plugin in the Lua runtime, runs real curl against a temporary loopback HTTP receiver, and checks method, topic, title, literal message bytes, acceptance reporting and header-injection refusal.
```sh
KCODE_NTFY_PLUGIN_DIR=/absolute/path/to/ntfy-notify cargo test -p plugin-runtime-lua --test ntfy -- --ignored
```
Loopback HTTP is permitted only for the test endpoint form `http://127.0.0.1:<port>`. Production endpoints must be HTTPS origins. Phone delivery must be checked separately with your chosen server/topic.ntfy-notify/plugin.toml
name = "ntfy-notify"
version = "0.1.0"
description = "Send a remote notification to a configured ntfy topic"
kind = "Lua"
[capabilities]
"run-tool" = true
[settings.endpoint]
type = "string"
default = "https://ntfy.sh"
description = "ntfy server base URL; HTTPS required except localhost for tests"
[settings.topic]
type = "string"
default = ""
description = "Your ntfy topic; configure before sending. Public topics are not private"ntfy-notify/main.lua
local function quote(text)
assert(type(text) == "string" and not text:find("%z"), "expected text without NUL bytes")
return "'" .. text:gsub("'", "'\\''") .. "'"
end
kcode.register_tool("send_notification", "Send a message to the user's configured ntfy topic. Use only when remote notification is requested. Returns server acceptance, not proof of phone delivery. Do not include secrets.", function(args)
local endpoint = kcode.settings.get("endpoint"):gsub("/+$", "")
local topic = kcode.settings.get("topic")
assert(type(topic) == "string" and #topic > 0 and #topic <= 128 and topic:match("^[%w_-]+$"), "configure a topic containing letters, digits, underscores or hyphens")
assert(endpoint:match("^https://[%w%.%-]+:?%d*$") or endpoint:match("^http://127%.0%.0%.1:%d+$"), "endpoint must be an HTTPS server origin (or localhost test server)")
local message = args.message
assert(type(message) == "string" and #message > 0 and #message <= 4096, "message must contain 1 to 4096 bytes")
local title = args.title or "kcode"
assert(type(title) == "string" and #title > 0 and #title <= 120 and not title:find("[%c]"), "title must contain 1 to 120 bytes without control characters")
local command = "printf '%s' " .. quote(message) .. " | curl --silent --show-error --fail --connect-timeout 5 --max-time 20 --request POST --output /dev/null --write-out '%{http_code}' --header "
.. quote("Title: " .. title) .. " --data-binary @- " .. quote(endpoint .. "/" .. topic)
local response = kcode.run_tool("bash", { command = command, timeout_seconds = 30 })
local result = response.structured
assert(type(result) == "table" and result.exit_code == 0, "notification request failed; inspect the shell result before retrying")
local status = tonumber(result.stdout)
assert(status and status >= 200 and status < 300, "ntfy did not accept the notification")
return { accepted = true, http_status = status, delivery_confirmed = false }
end, { schema = {
type = "object", properties = {
message = { type = "string", description = "Notification text; no secrets" },
title = { type = "string", description = "Optional title, default kcode" },
}, required = { "message" }, additionalProperties = false,
}, read_only = false })