Calling OpenClacky's Native APIs
Many features users ask for are already natively supported by the OpenClacky main service — sessions, trash, skills, memories, cron tasks, billing, media generation… each has a ready-made HTTP endpoint. An extension panel doesn't need to rebuild these capabilities — it can call the main service's endpoints directly.
For example, a panel can restore files already recorded in the host trash using
POST /api/trash/restore. The restore API does not turn an arbitrary file deletion into a recoverable operation.
How to call: the panel shares the host's origin
A Web UI panel runs on the same page and origin as the host. Use fetch("/api/..."): after host authentication, the browser includes its access-key cookie on same-origin requests. This is not a separate extension credential or an authentication bypass; handle unsuccessful responses.
// Inside a panel's view.js, fetch a main-service endpoint directly
const res = await fetch("/api/skills");
const data = await res.json();
console.log("installed skills:", data.skills);
This section covers the frontend panel calling main-service endpoints. An extension's backend
handler.rbshould notfetchthese endpoints — to drive sessions from the backend, use the white-listed methods (create_session/submit_task/dispatch_to_session, see HTTP API Extensions). The endpoints listed here are for the panel frontend.
Endpoints live under /api/. Most return JSON; downloads return bytes. The following is an integration reference, not a permission allowlist. Read-only queries, writes, and billed operations have different side effects.
Sessions
| Method | Path | Purpose |
|---|---|---|
| GET | /api/sessions |
Paginated session summaries (not all history) |
| GET | /api/sessions/:id |
A single session's detail |
| GET | /api/sessions/:id/messages |
The session's message history |
| GET | /api/sessions/:id/files |
Files in the session's working directory |
| GET | /api/sessions/:id/git/:action |
The session's git status/diff, etc. |
| GET | /api/sessions/:id/time_machine |
The session's time-machine snapshots |
| POST | /api/sessions |
Create a session |
| PATCH | /api/sessions/:id/model |
Switch the session's model |
| PATCH | /api/sessions/:id/working_dir |
Change the session's working directory |
Session pagination: GET /api/sessions returns {sessions, has_more, groups}. limit defaults to 15 and is capped at 50 non-pinned rows; pinned rows are additional. Pass the last non-pinned row's updated_at (falling back to created_at) as the ISO8601 before cursor to load older rows. Pinned rows appear only on the first page. Filters include q, q_scope=name|content, date, type, and comma-separated exclude_type. Pagination with before excludes project sessions; typed queries also exclude projects. Do not treat one page's length as the global session count.
Message replay: GET /api/sessions/:id/messages?limit=20&before=<timestamp> returns {events, has_more}, with UI replay events, not raw model-message objects. The default limit is 20, capped at 100 history messages; one message may expand into multiple events. Fetch additional history only when needed.
Create a session POST /api/sessions:
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "My new session", // required
agent_profile: "general", // optional, defaults to general
working_dir: "/path/to/dir", // optional; default/inheritance rules below
model_id: "…", // optional, a model id from GET /api/config
project_id: "a1b2c3d4" // optional, bind to a project (see Projects)
}),
});
const { session } = await res.json(); // 201 → { session: {...} }
When working_dir is omitted/blank, the host uses configured default_working_dir, falling back to ~/clacky_workspace. If a valid project_id is also supplied and that project has a working directory, it takes precedence over that default. Omitting the field does not inherit the session currently on screen. Creating a session creates the target directory if missing.
PATCH /api/sessions/:id/model and /working_dir mutate a running session — these are operations with side effects. Call them only when the user explicitly asks; they should not silently alter a session.
Projects
Projects are named groups sessions can be assigned to - each with an optional working_dir new sessions inherit. A panel can list, create, update, and delete them via the main service.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/projects |
List all projects |
| POST | /api/projects |
Create a project |
| PATCH | /api/projects/:id |
Update a project |
| DELETE | /api/projects/:id |
Delete a project |
| PATCH | /api/sessions/:id/project |
Assign a session to a project |
Create a project POST /api/projects:
await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "My project", // required
working_dir: "/path/to/dir", // optional, inherited by new sessions
description: "…", // optional
color: "#6366f1", // optional
icon: "code", // optional
}),
});
// 201 -> { project: { id, name, … } }
Pass project_id to POST /api/sessions to bind the new session to a project - the project's working_dir is inherited unless an explicit one is given.
Trash (file recovery)
These APIs list and restore existing trash records. They do not provide a general “move any file to trash” HTTP operation.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/trash |
List files in the trash (add ?project=<path> to filter a project) |
| POST | /api/trash/restore |
Restore a single file to its original location |
| GET | /api/trash/sessions |
List sessions in the trash |
| POST | /api/trash/sessions/restore |
Restore a deleted session |
Restore a file POST /api/trash/restore:
await fetch("/api/trash/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_root: "/path/to/project", // project root the file belongs to
original_path: "/path/to/project/notes.md", // the file's original path
}),
});
// 200 → { ok: true, restored_file, message }
// If a file already exists at the original location, returns 422 — no overwrite
GET /api/trash returns:
{
"ok": true,
"files": [
{ "original_path": "…", "file_size": 1234, "deleted_at": "…",
"project_root": "…", "project_name": "…" }
],
"projects": [ { "project_root": "…", "file_count": 3, "total_size": 4567 } ],
"total_count": 3
}
Skills, Agents, Channels, MCP
| Method | Path | Purpose |
|---|---|---|
| GET | /api/skills |
Installed skills |
| GET | /api/agents |
Available agents |
| GET | /api/sessions/:id/skills |
Skills available in a session |
| GET | /api/agents/:id/skills |
Skills bound to an agent |
| GET | /api/providers |
Available models / providers |
| GET | /api/channels |
Configured IM channels |
| GET | /api/mcp |
Configured MCP servers |
All read-only — suited for "what capabilities are installed" overview panels.
Memories
| Method | Path | Purpose |
|---|---|---|
| GET | /api/memories |
List long-term memories |
| POST | /api/memories |
Write a memory |
Write a memory POST /api/memories:
await fetch("/api/memories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: "my-note.md", // must end in .md, no path separators
content: "# Title\nBody…",
}),
});
// 201 → { ok: true, memory: {...} }
// Already exists → 409
Cron tasks
| Method | Path | Purpose |
|---|---|---|
| GET | /api/cron-tasks |
List cron tasks |
| POST | /api/cron-tasks |
Create a cron task |
Create a cron task POST /api/cron-tasks:
await fetch("/api/cron-tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Daily briefing", // required
content: "Summarize today's news…", // required, the task prompt
cron: "0 9 * * *", // required, standard 5-field cron
enabled: true, // optional, defaults to true
}),
});
// 201 → { ok: true, name }
Billing / usage
| Method | Path | Purpose |
|---|---|---|
| GET | /api/billing/summary |
Usage summary |
| GET | /api/billing/daily |
Usage by day |
| GET | /api/billing/records |
Detailed records |
| GET | /api/billing/sessions |
Usage by session |
Suited for "how much did I spend this month" usage panels — all read-only.
Files
| Method | Path | Purpose |
|---|---|---|
| POST | /api/upload |
Upload a file (multipart, field name file) |
| POST | /api/file-action |
Open / reveal / download / display path / save text |
Open a file POST /api/file-action:
await fetch("/api/file-action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "/path/to/file.pdf",
action: "open", // "open" default app | "reveal" show in file manager | "download"
}),
});
File Action Contract
Send a JSON body with path and action:
| Action | Result / side effect |
|---|---|
open (default) |
Open via the server machine's default application; {ok: true}, or 501 for unsupported OS. |
reveal |
Reveal on the server machine in its file manager; {ok: true}. |
download |
Binary attachment response; use res.blob(), not res.json(). |
display-path |
{ok: true, path}; convert the resolved path for display (Windows path under WSL). Requires the path to exist. |
save |
Also requires content (text, empty string allowed). Creates missing parents, writes UTF-8, overwrites an existing file, returns {ok: true}. No automatic trash backup. |
Paths are normalized using the host's Windows-to-WSL conversion before expansion. Use server-side absolute paths, not file:// URLs or already percent-encoded strings. Normalization does not make an arbitrary path safe to write: obtain user authorization before saving. A missing/empty path returns 400. For save, missing/null content returns 400. For every non-save action, path existence is checked before the action is validated: a missing path returns 404 even for an invalid action; an invalid action with an existing path returns 400. Exceptions return 500.
A direct /api/file-action save does not broadcast workspace:fileSaved. For current-workspace editing that should notify other panels, reuse Clacky.Workspace.saveFileText(entry, content); see Web UI Extensions.
Development UI Helpers
| Method | Path | Effect |
|---|---|---|
| POST | /api/ui/show_ext_refresh |
Ask the target session's browser to show the extension-refresh button. |
| POST | /api/ui/open_aside |
Ask it to expand the session aside. |
Both accept JSON {session_id: "..."}. Empty/missing id returns 400; success returns {ok: true} with 200 after broadcasting. The response does not prove a matching browser is connected, the session is active there, or a new panel rendered. They do not restart the server or reload required Ruby contributions.
After editing an extension, request the refresh button once; for a session-aside panel, also request opening the aside. In an OpenClacky agent shell, use the injected host/port/session variables rather than guessing the service:
curl -sS --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/show_ext_refresh" \
-H "Content-Type: application/json" \
-d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
curl -sS --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/open_aside" \
-H "Content-Type: application/json" \
-d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
Check that the variables are populated; do not invent a session id. --noproxy '*' prevents a shell proxy from intercepting the local call. Loopback is trusted by the host; non-loopback calls must use its configured access-key authentication. If the UI is not connected, ask the user to refresh manually. For code already in a panel, use Clacky.Aside.open() / close() instead of this HTTP bridge.
Media generation (billed)
| Method | Path | Purpose |
|---|---|---|
| POST | /api/media/image |
Generate an image |
| POST | /api/media/video |
Generate a video |
| POST | /api/media/audio/speech |
Text-to-speech |
These endpoints incur real charges. They should be called only on an explicit user action (e.g. clicking a "Generate" button) — not automatically inside a panel's
render, and not in a loop, or they will keep burning the user's quota.
A Full Example: Restore an Existing Trash Record
This example filters the trash by the active workspace directory, shows the target path for confirmation, and restores only after approval. It does not query the global list and silently restore another project's first record.
// panels/trash-tool/view.js
Clacky.ext.ui.mount("session.aside", (container, ctx) => {
if (!ctx.sessionId) return null;
const btn = document.createElement("button");
btn.className = "btn-secondary";
btn.textContent = "Restore latest file in this workspace";
let disposed = false;
const controller = new AbortController();
btn.addEventListener("click", async () => {
if (btn.disabled) return;
const state = Clacky.Workspace.state;
const projectRoot = state.workingDir;
const isCurrent = () => !disposed &&
Clacky.ext.context.sessionId === ctx.sessionId &&
state.sessionId === ctx.sessionId && state.workingDir === projectRoot;
if (!projectRoot || !isCurrent()) return;
btn.disabled = true;
try {
const res = await fetch("/api/trash?project=" + encodeURIComponent(projectRoot),
{ signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const trash = await res.json();
if (!isCurrent()) return;
const latest = trash.files[0];
if (!latest) return Clacky.Modal.toast("No files in this workspace's trash");
const confirmed = await Clacky.Modal.confirm(
"Restore this file?\n" + latest.original_path);
if (!confirmed || !isCurrent()) return;
const response = await fetch("/api/trash/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_root: latest.project_root,
original_path: latest.original_path,
}),
});
const out = await response.json();
if (!response.ok || !out.ok) throw new Error(out.error || `HTTP ${response.status}`);
if (isCurrent()) Clacky.Modal.toast("Restored", "success");
} catch (error) {
if (isCurrent() && error.name !== "AbortError") {
Clacky.Modal.toast(error.message || "Restore failed", "error");
}
} finally {
if (!disposed) btn.disabled = false;
}
});
container.appendChild(btn);
return () => { disposed = true; controller.abort(); };
}, { tab: { id: "trash-tool-recovery", label: () => "Recovery" } });
The button prevents duplicate clicks; read failures, cancellation, a changed session/working directory, or teardown before submission prevent the restore. Teardown cancels the outstanding read. A restore already submitted to the server is not undone by switching sessions or aborting a browser request; if its response is lost, check the target before retrying. The example never deletes files or creates trash records.
Boundaries: what's not here
This is a curated integration reference, not an enforced extension permission sandbox. An unlisted endpoint is neither automatically supported nor automatically blocked. Do not call host control-plane actions (global configuration, restart, licensing, installation, or permanent deletion) just because a route exists. Verify the current contract and user authorization first; do not invent compatibility endpoints or bypass authentication.