Calling Native APIs

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.rb should not fetch these 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.