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.yml plus a handler under ~/.clacky/ext/local/<id>/. A broken extension cannot take down other extensions or the main service; failed loads are isolated and clacky ext verify lists 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.rb takes effect on the next request — no restart. Editing ext.yml (adding/removing the api contribution) 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 require your own gem; do not poke instance_variable_get into 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 verify first 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.rb takes effect on the next request; editing ext.yml needs a reload.
  • clacky ext list is 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 key
  • GET /api/ext/gh-webhook/recent — local read, access key required (auto-allowed on loopback)