HTTP API Extensions
Want to add a backend endpoint to OpenClacky — to receive a webhook, expose a JSON endpoint to a desktop tool, back a WebUI panel with data, or surface internal state to a small CLI script — without forking the gem? An HTTP API is one kind of extension-container contribution: declare contributes.api in ext.yml and a handler.rb gets mounted into the running OpenClacky HTTP server, automatically inheriting access-key auth, timeouts, JSON error envelopes, and prefixed logging.
API backends now ship inside an extension container — one
ext.ymlplus a handler under~/.clacky/ext/local/<id>/. A broken extension cannot take down other extensions or the main service; failed loads are isolated andclacky ext verifylists why.
How it works
An extension container declares its API backend in ext.yml:
# ~/.clacky/ext/local/my-dashboard/ext.yml
id: my-dashboard
name: My Dashboard
version: "0.1.0"
contributes:
api: api/handler.rb # single handler, mounted at /api/ext/my-dashboard/
When the web server starts, OpenClacky resolves every container, loads the handler.rb each declares, and mounts it at:
/api/ext/<id>/<sub-path>
<id> is the container id (lowercase letters, digits, _, - only). This prefix is mandatory and immutable — it's both the routing namespace and the failure-isolation boundary. The handler.rb should define a class inheriting from Clacky::ApiExtension, declaring routes via the DSL inside the class body.
Broken extensions (syntax error, missing base class, no routes declared, etc.) are skipped without aborting the main process.
Editing
handler.rbtakes effect on the next request — no restart. Editingext.yml(adding/removing theapicontribution) requires a reload.
Directory layout
~/.clacky/ext/local/my-dashboard/
├── ext.yml # manifest: declares contributes.api
└── api/
├── handler.rb # required: defines the ApiExtension subclass
└── data/ # optional: extension-private data, accessed via data_path(...)
data/ is created on demand by data_path(*parts). It's meant for small local state (a SQLite file, JSON, an append log). Keep heavy data elsewhere.
Route DSL
Inherit from Clacky::ApiExtension and use get / post / put / patch / delete:
class MyDashboardExt < Clacky::ApiExtension
get "/summary" do
json(sessions: session_manager.list.size, started_at: server_start_time)
end
get "/sessions/:id" do
sess = session_manager.find(params[:id]) or error!("not found", status: 404)
json(id: sess.id, name: sess.name)
end
post "/notes" do
text = json_body["text"].to_s
error!("text required", status: 422) if text.empty?
File.write(data_path("notes.txt"), "#{text}\n", mode: "a")
json(ok: true)
end
end
Resulting mounts:
| Method | Path |
|---|---|
| GET | /api/ext/my-dashboard/summary |
| GET | /api/ext/my-dashboard/sessions/:id |
| POST | /api/ext/my-dashboard/notes |
Path parameters
:name placeholders are captured into params[:name] (URL-decoded, always strings).
Request body
json_body parses an application/json body lazily and caches it. Parse failures return an empty hash (no exception). If you need strict validation, check yourself and call error! with 422.
Query string
query returns the raw req.query (a WEBrick hash).
Handler context
Handler blocks run via instance_exec on the extension instance. The following methods are whitelisted:
| Name | Purpose |
|---|---|
params |
Path parameters (symbol keys) |
query |
Query string parameters |
req / res |
Raw WEBrick request/response (escape hatch, avoid in normal code) |
json(...) / text(...) |
Send a response and halt the handler |
error!(msg, status:, **extra) |
Send a JSON error and halt the handler |
json_body |
Parsed JSON body |
data_path(*parts) |
Path under the extension's private data dir (auto-creates) |
ext_dir / ext_id |
Extension directory / extension id |
config |
The config: field from ext.yml (a hash) |
session_manager |
The host process's SessionManager (read-only listing) |
agent_config |
Current AgentConfig |
server_start_time |
When the server started |
logger |
Logger that auto-prefixes every line with [api_ext:<id>] |
This boundary is deliberately narrow. If you need other capabilities (sending IM messages, calling LLMs, doing filesystem work), use Ruby's stdlib or
requireyour own gem; do not pokeinstance_variable_getinto host-process internals — it breaks isolation.
Response helpers
json(foo: 1, bar: 2) # 200 + {"foo":1,"bar":2}
json({ items: [] }, status: 201) # custom status
text("pong") # 200 text/plain
error!("not found", status: 404) # 404 + {"error":"not found"}
error!("invalid", status: 422, fields: ["text"]) # 422 + extra fields
These helpers raise Halt to stop the handler — code after them does not run. This is intentional and makes early returns cleaner.
If a handler finishes without calling any response helper, the framework returns 204 No Content.
Timeouts
Every route has a timeout. Default 10 s, hard cap 600 s. Two ways to configure:
class MyExt < Clacky::ApiExtension
timeout 30 # class-level default
get "/quick" do
json(ok: true) # uses 30 s
end
post "/slow", timeout: 120 do # per-route override
long_running_thing
json(ok: true)
end
end
When the timeout fires the response is 503 + {"error":"handler timed out"}.
Config and public endpoints (via ext.yml)
Optional config and public-endpoint consent live in the container's ext.yml:
id: gh-webhook
name: GitHub Webhook
version: "0.1.0"
public: true # top-level opt-in for public endpoints (see below)
config: # arbitrary config, read via config["key"] in the handler
webhook_secret: ...
contributes:
api: api/handler.rb
Public endpoints (no access key)
OpenClacky's HTTP server requires an access key for all non-loopback requests by default. For endpoints that external systems call into (webhooks), you need a double declaration to opt in:
class GhWebhookExt < Clacky::ApiExtension
public_endpoint "/webhook"
post "/webhook" do
# ... verify X-Hub-Signature-256 yourself
json(ok: true)
end
end
# ext.yml
public: true
Both must be declared for it to take effect — public_endpoint in code is the per-route contract, public: true in ext.yml is the explicit container-level consent. When installing a new extension, this "double signature" makes it instantly visible whether it opens a public ingress.
A public endpoint bypasses access-key auth only, not all security. Always verify signatures / HMACs / IP allowlists yourself.
Command-line workflow
1. Generate scaffold
clacky ext new my-dashboard
Creates ~/.clacky/ext/local/my-dashboard/ with a runnable hello panel + an api/handler.rb mounted at /api/ext/my-dashboard/. If you only want the backend, delete the panels entry from ext.yml and the panels/ folder.
2. Verify loading
clacky ext verify
Sample output:
[OK] my-dashboard (api → /api/ext/my-dashboard/, local)
[OK] gh-webhook (api → /api/ext/gh-webhook/, local)
[ERR] broken-one (api.handler.missing) — handler file not found [api/handler.rb]
The command exits non-zero if there is any error, suitable for CI.
3. List resolved containers
clacky ext list
Prints every container and its contributed units — handy for confirming the API mount point.
4. Call the endpoint
After starting the server:
# loopback is auto-allowed
curl http://127.0.0.1:7070/api/ext/my-dashboard/summary
# remote calls need the access key
curl -H "X-Clacky-Key: $(cat ~/.clacky/access_key)" \
http://your-host:7070/api/ext/my-dashboard/summary
Error handling
| Situation | HTTP status | Body |
|---|---|---|
| Unknown extension | 404 | {"error":"extension '<id>' not found"} |
| No matching route | 404 | {"error":"no route for <METHOD> <path>"} |
Handler called error! |
custom | {"error":"...", ...} |
| Handler raised anything else | 500 | {"error":"<message>"} (full trace in logs) |
| Timeout | 503 | {"error":"handler timed out"} |
| Public endpoint not authorized | 401 | Framework's standard envelope |
A handler exception never propagates beyond the current request. An error log line with the [api_ext:<id>] prefix is also written.
Relation to other contributions
An api contribution rarely stands alone — it's one of the seven contribution types a container can bundle:
| Contribution | Role | When to use it instead |
|---|---|---|
| patches | Override behavior of existing host code | You want to change an existing method |
| hooks | Intercept / audit tool calls | You want a security policy at the tool layer |
| channels | Plug in IM platforms | You want to add Slack / Discord / etc. |
| panels | Front-end panels / buttons | You want to add UI |
| api (this doc) | Add an HTTP endpoint | You want to be called over HTTP |
In practice, api often pairs with panels in the same container: the panel draws the UI, the api serves data. They talk via Clacky.ext.fetch("/api/ext/<id>/<path>"), which transparently handles the access key on the front-end side.
Debugging tips
- When a load fails, run
clacky ext verifyfirst to see the reason and location. - Use
logger.info "..."inside handlers; output goes to~/.clacky/logs/with the extension prefix. - Full backtraces of handler exceptions go to the host process's stderr, not the response body.
- Editing
handler.rbtakes effect on the next request; editingext.ymlneeds a reload. clacky ext listis the fastest way to confirm "what's actually mounted".
Full example
~/.clacky/ext/local/gh-webhook/api/handler.rb:
require "openssl"
class GhWebhookExt < Clacky::ApiExtension
timeout 15
public_endpoint "/webhook"
post "/webhook" do
secret = config["webhook_secret"].to_s
error!("not configured", status: 500) if secret.empty?
sig = req["X-Hub-Signature-256"].to_s
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, req.body.to_s)
error!("bad signature", status: 401) unless Rack::Utils.secure_compare(sig, expected)
event = req["X-GitHub-Event"]
payload = json_body
File.write(data_path("events.log"), "#{Time.now.iso8601} #{event} #{payload['action']}\n", mode: "a")
logger.info("received #{event} action=#{payload['action']}")
json(ok: true)
end
get "/recent" do
log = data_path("events.log")
text(File.exist?(log) ? File.read(log) : "")
end
end
~/.clacky/ext/local/gh-webhook/ext.yml:
id: gh-webhook
name: GitHub Webhook
version: "0.1.0"
public: true
config:
webhook_secret: <fill in via env or here>
contributes:
api: api/handler.rb
Result:
POST /api/ext/gh-webhook/webhook— GitHub hits this directly, no access keyGET /api/ext/gh-webhook/recent— local read, access key required (auto-allowed on loopback)