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 ship inside an extension container. Load errors are logged and bad handlers skipped. Ruby runs in the host process, not a sandbox: exception handling and timeouts do not protect against all side effects or resource exhaustion.
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.
Handler reload, manifest metadata, and required Ruby dependencies have different lifecycles. See Applying Changes before choosing browser refresh or a server restart.
Directory layout
~/.clacky/ext/local/my-dashboard/
├── ext.yml # manifest
└── api/
└── handler.rb # ApiExtension subclass
~/.clacky/ext-data/my-dashboard/ # persistent data, outside the package
data_path(*parts) creates the base ~/.clacky/ext-data/<id>/ directory and returns a joined path. It does not create nested parents or validate user-supplied path components. Validate identifiers and create needed subdirectories explicitly; do not pass arbitrary request paths to it.
Persist user data here, not under ext_dir: package updates/removal replace or delete the code tree. Extension data is kept on uninstall by default, unless the user explicitly chooses to remove it.
Route DSL
Inherit from Clacky::ApiExtension and use get / post / put / patch / delete:
class MyDashboardExt < Clacky::ApiExtension
get "/summary" do
json(sessions: session_manager.all_sessions.size, started_at: server_start_time)
end
get "/sessions/:id" do
sess = session_manager.load(params[:id]) or error!("not found", status: 404)
json(id: sess[:session_id], name: sess[:name])
end
post "/notes" do
body = json_body
error!("object required", status: 422) unless body.is_a?(Hash)
text = 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 symbol keys (params[:name]); values are URL-decoded strings. Query-string and JSON object keys remain strings.
Request body
json_body parses and caches JSON. Parse failures return {}, but valid JSON may be an array, scalar, or null. Validate its type and required fields before indexing it; use error! with 422 for invalid input.
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 |
send_data(bytes, content_type:, filename:, status:) |
Send raw bytes as an attachment download and halt the handler |
error!(msg, status:, **extra) |
Send a JSON error and halt the handler |
json_body |
Parsed JSON body |
data_path(*parts) |
External persistent path; only the base directory is auto-created (see above). |
ext_dir / ext_id |
Extension directory / extension id |
config |
The config: field from ext.yml (a hash) |
session_manager |
SessionManager: all_sessions(current_dir: nil, limit: nil) returns data hashes; load(id) returns a matching hash or nil (supports an id prefix). Do not poll full history for panel counters. |
agent_config |
Current AgentConfig |
registry |
The host's live session registry (exist?, get, with_session, …) |
project_manager |
The host's ProjectManager (all, find(id), create(name:, …), update(id, …), delete(id)) |
create_session(name:, prompt:, working_dir:, profile:, display_message:, project_id:, source:) |
Create a brand-new session; when prompt is given, kick off its first task immediately. Returns the new session_id. project_id: binds the session to a project and inherits its working_dir. source: accepts only :manual (default) or :ext — see the note below. |
submit_task(session_id, prompt, display_message:, interrupt:) |
Enqueue a turn into an existing live conversation and return immediately. 409 if busy unless interrupt: true (which supersedes the current turn) |
dispatch_to_session(session_id, prompt, model:, forbidden_tools:) |
Run a one-off side task synchronously on a fork of the session's agent — reuses its cached context and unified billing, never touches the main conversation. Returns { text: "..." }, or { busy: true } if the session/server is busy |
server_start_time |
When the server started |
logger |
Logger that auto-prefixes every line with [api_ext:<id>] |
Most helpers are read-only conveniences, but
create_session/submit_task/dispatch_to_sessionare powerful: they can spawn sessions and drive the agent. Treat any endpoint that exposes them as privileged — validate input and rely on the built-in access-key auth. For capabilities outside this table (sending IM messages, calling LLMs directly, arbitrary filesystem work), use Ruby's stdlib orrequireyour own gem; do not pokeinstance_variable_getinto host-process internals — it breaks isolation.
source: — keep the default unless you really need otherwise. ⚠️ Only :manual
and :ext are accepted; anything else is rejected with 400.
:manual(default) — the session appears in the sidebar like any user session. This is what almost every extension should use. A session the user asked for is a session the user should be able to find.:ext— the session is collapsed under a single "Extensions" entry in the sidebar and gets its own 200-session cleanup pool, independent from the regular and cron pools. Use it only when your extension creates sessions the user did not individually ask for (bookkeeping, background workers, one per incoming webhook) and would otherwise flood the list. Do not reach for it just to keep the sidebar tidy: folded sessions are easy to overlook, and the separate pool silently consumes storage until entries are evicted.
Sessions bound to a project (project_id:) always appear in that project's area
regardless of source, and they count toward the regular cleanup pool rather than
the :ext one — so a project-scoped :ext session is never folded away.
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":"request 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 (loader.error) — handler file not found: api/handler.rb [/path/to/broken-one/api/handler.rb]
The absolute path in errors varies by machine. The command exits non-zero on errors, but does not execute the handler or check Ruby syntax. Run the generated tests and exercise the HTTP endpoints separately.
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 "Authorization: Bearer <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>"} (trace excerpt in logs) |
| Timeout | 503 | {"error":"request timed out"} |
| Public endpoint not authorized | 401 | Framework's standard envelope |
StandardError exceptions become a 500 response and a prefixed log entry. This does not isolate arbitrary Ruby side effects from the host process.
Relation to other contributions
An api contribution rarely stands alone — it's one of the eight 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 |
An api often pairs with a panels contribution. Use fetch("/api/ext/<id>/<path>"), not the nonexistent Clacky.ext.fetch. Same-origin requests carry the host-auth cookie after authentication; see Host APIs.
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. - Handler error logs include a traceback excerpt; the response body contains the error message, not the stack.
- Follow the reload matrix in Extension System Overview; do not equate a browser refresh with reloading required Ruby files.
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)
valid = sig.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(sig, expected)
error!("bad signature", status: 401) unless valid
event = req["X-GitHub-Event"]
payload = json_body
error!("object required", status: 422) unless payload.is_a?(Hash)
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)