Octopus

Octopus › Extensions › Build an extension

Build an extension for Octopus.

An extension is a new section of Octopus, made of plain HTML, CSS and JavaScript. This guide takes you from an empty folder to an extension your team installs in one click: the files, the permissions, the SDK and the marketplace.

What an extension is

Octopus keeps your tasks, terminals, AI sessions, docs and git in one window. An extension adds what's missing for your work: your logs, an internal API, a dashboard, a standup summary. It gets a button of its own in the activity bar and a full section, beside the projects.

  • Plain files, no build step. A folder with a manifest.json, an index.html and whatever scripts and styles it needs.
  • Isolated. It runs in a frame of its own and talks to Octopus only through its SDK, window.octopus.
  • Allowed by you. It reaches only what its manifest asks for, and only after you say yes.
  • Live. Octopus reloads it every time one of its files changes.

Create one with Claude

The quickest way is to describe it. Octopus writes the extension with Claude Code next to a live preview, and you refine it by asking.

  1. Open the Extensions section and press Create with Claude.
  2. Give it a Name and say What it should do, as precisely as you can: “Query our Loki logs: ask in plain words, see the volume over time, group errors into patterns, open a trace.” If you have more than one Claude account, pick the one that writes it.
  3. A Claude Code session starts in the extension's folder, beside the page. Watch it take shape, and ask for changes in the session. From its header the Claude pane folds to a strip at the side or expands over the page, and stays as you left it.
  4. When it asks for permissions (a site, your tasks, Claude), Octopus shows you what and waits for your yes.
  5. Press Done. To change it later, press Edit on its page: a new session picks up where you left off.

Every new extension comes with a CLAUDE.md that teaches Claude the rules and the whole SDK, so what it writes follows this guide. You can open the folder in your editor at any time and change it by hand.

The files

Every extension is a folder in ~/.octopus/extensions/. The folder's name is the extension's id.

~/.octopus/extensions/standup/
├── manifest.json   # name, icon, version, permissions
├── index.html      # the page Octopus shows
├── app.js          # your code, an ES module
├── CLAUDE.md       # the rules and the SDK, for Claude (made for you)
└── README.md       # optional: shown in the marketplace

Octopus watches the folder: a new extension shows up as soon as its manifest.json is there, and a change to any file reloads it.

Tutorial: a standup note

Let's build one by hand. Standup lists what's in progress and in review across every project, opens a task on a click, and asks Claude for a three-line note you can paste in your team's chat. It needs two permissions: tasks and claude.

1. The manifest

Create the folder ~/.octopus/extensions/standup and, in it:

manifest.json
{
  "name": "Standup",
  "description": "What's in progress and in review, and a standup note written by Claude.",
  "icon": "comment-discussion",
  "version": "0.1.0",
  "author": "Your name",
  "stage": "alpha",
  "sdk": 1,
  "permissions": { "tasks": true, "claude": true }
}

The Standup button appears in the activity bar right away. The first time you open it, Octopus asks whether it can read your tasks and use Claude.

2. The page

Link Octopus's stylesheet and SDK, both served by Octopus itself, then your module.

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Standup</title>
    <link rel="stylesheet" href="/__octopus/base.css" />
    <script src="/__octopus/sdk.js"></script>
    <style>
      body { padding: 16px 20px; }
      ul { list-style: none; padding: 0; display: grid; gap: 4px; }
      li { padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; }
      li:hover { background: var(--bg-hover); }
      #note { white-space: pre-wrap; }
    </style>
  </head>
  <body>
    <div class="toolbar">
      <button class="primary" id="write">Write the note</button>
      <button class="ghost" id="copy">Copy</button>
    </div>
    <ul id="tasks"></ul>
    <div class="card" id="note"><span class="muted">No note yet.</span></div>
    <script type="module" src="app.js"></script>
  </body>
</html>

3. The code

Wait for octopus.ready() before anything else: it says which extension this is, the theme and the projects.

app.js
const { projects } = await octopus.ready();
const projectName = Object.fromEntries(projects.map((p) => [p.id, p.name]));
const STATUS = { doing: "In progress", review: "Review" };
const list = document.getElementById("tasks");
const note = document.getElementById("note");

// The open work, across every project: a row per task, a click shows it on its board.
async function load() {
  const tasks = (await octopus.tasks.list()).filter((t) => STATUS[t.status]);
  list.replaceChildren(...tasks.map((t) => {
    const li = document.createElement("li");
    const badge = Object.assign(document.createElement("span"), { className: "badge", textContent: STATUS[t.status] });
    const where = Object.assign(document.createElement("span"), { className: "faint", textContent: projectName[t.projectId] ?? "No project" });
    li.append(badge, " ", t.title, " ", where);
    li.onclick = () => octopus.tasks.reveal(t.id);
    return li;
  }));
  return tasks;
}

const write = document.getElementById("write");
write.onclick = async () => {
  write.disabled = true;
  note.textContent = "Claude is writing…";
  try {
    const lines = (await load()).map((t) => `- ${STATUS[t.status]}: ${t.title} (${projectName[t.projectId] ?? "no project"})`);
    note.textContent = await octopus.claude.ask(
      `Write a standup note in three short lines from these tasks. Plain text, no preamble.\n${lines.join("\n")}`,
      { model: "haiku" },
    );
  } catch (err) {
    note.textContent = err.message;
  } finally {
    write.disabled = false;
  }
};

document.getElementById("copy").onclick = () => {
  octopus.copy(note.textContent);
  octopus.toast("Copied", "success");
};

// Fresh every time the section is shown.
octopus.on("visible", (shown) => shown && load());
load();

4. Try it

Save, and Octopus reloads the section. Press Write the note: a few seconds later Claude's note is there, ready to copy. Everything else is the same loop: change a file, look, change again. When it's done, drop "stage" from the manifest and share it.

Something doesn't work? Every SDK call returns a promise that rejects with an Error saying why: a permission the manifest doesn't declare, a host that isn't in permissions.http, no project open. Show err.message on the page while you build.

The manifest

manifest.json says what the extension is and what it may reach. Only name is required.

FieldWhat it is
nameShown in the activity bar, the list and the marketplace.
descriptionOne or two sentences on what it does. Claude reads it too, when it writes or edits the extension.
iconA codicon name: pulse, graph, database, output, beaker…
versionLike 1.2.0. Compared with the marketplace's to offer updates.
authorYou, or your team.
stage"alpha" or "beta" while it isn't finished: a label in the list and the marketplace. Leave it out once it is.
entryThe page to show. Default index.html.
sdkThe SDK protocol it was written for: 1.
permissionsWhat it may reach besides its own files. See below.
dependenciesThe extensions it needs, with the oldest version that will do: { "calendar": "1.2.0" }. See Extensions that need others.

Permissions

An extension starts with nothing but its own page. Each permission it declares is shown to you before it first runs, and Octopus asks again whenever the list grows. Every call is checked by Octopus's backend, not by the page. Ask only for what you need.

PermissionLets it
httpSend requests through octopus.http.fetch, only to these URL prefixes: ["https://api.example.com/v1", "http://loki.internal"].
claudeAsk Claude Code with octopus.claude.ask and chat, open Claude sessions in a project, show them in its page and close the ones it opened.
tasksRead your tasks, create new ones, change them (status, priority, tags, blocked, archived), show one on its board.
terminalsSee Octopus's terminals and what they show, read-only: nothing can be typed into them.
docsWrite Markdown and SVG files inside a project's documentation folder, and open them.
statusBarShow an item of its own in the Mac's menu bar.
sharedRead what other extensions share, by their ids: ["calendar"].
webappShow a website itself. See A website as an extension.

Look like Octopus

Link /__octopus/base.css and the extension follows Octopus's theme, light or dark, as it changes. Use its CSS variables for every colour, never hard-coded ones:

  • Backgrounds: --bg-editor, --bg-widget, --bg-input, --bg-hover, --bg-selected, and --border.
  • Text: --fg, --fg-strong, --fg-muted, --fg-faint, --accent, --link.
  • States: --error, --warn, --success; syntax colours --hl-keyword, --hl-string, --hl-number…
  • Shapes and type: --radius-*, --font-ui, --font-mono.

Classes ready to use: button.primary, button.ghost, .card, .badge (with .error, .warn, .success), .toolbar, .muted, .faint, .mono, .spinner. Aim for a dense, quiet UI like an editor's: 13px text, small paddings, no big headers.

Drawing on a canvas or with a chart library? Redraw on octopus.on("theme", …): the variables already hold the new colours.

The SDK

/__octopus/sdk.js defines window.octopus. Every call returns a promise.

Start

const { extension, theme, projects, workProjectId } = await octopus.ready();

HTTP

Made by Octopus, not the page: no CORS, and the Mac's network (your VPN) applies. Any status resolves; only a failure to connect rejects. Up to 64 MB.

const res = await octopus.http.fetch("https://api.example.com/v1/deploys?env=prod", {
  method: "POST",                 // default GET
  headers: { Authorization: "Bearer …" },
  body: { any: "json" },          // or a string
  timeoutMs: 60000,
});
res.status; res.ok; res.headers; res.text(); res.json();

// Images and other binary files
const img = await octopus.http.fetch("https://host/chart.png", { binary: true });
el.src = img.dataUrl();

Claude

Claude Code without a terminal, in the Claude account of the project you're working on. It reads the extension's own files and uses skills, but can't run commands, edit files or reach the web: give it the data. An answer takes 5 to 30 seconds, so show progress; "haiku" and "sonnet" answer faster. Ask for JSON when you need data.

const answer = await octopus.claude.ask("Group these errors into patterns, as JSON: …", { model: "sonnet" });

// A conversation: each turn continues the last.
let { text, session } = await octopus.claude.chat("…", { system: "You are…" });
({ text, session } = await octopus.claude.chat("and then?", { session }));

// A full Claude Code session in a project's terminals, started with this prompt. It resolves with
// the terminal's id; background: true keeps the extension in front.
const termId = await octopus.claude.open("Investigate the 502s on checkout", { projectId, title: "502s", background: true });

To show that session inside your page, see Claude in your page.

Projects and tasks

const projects = await octopus.projects.list();       // [{ id, name, path, color }]
const tasks = await octopus.tasks.list({ projectId });
// [{ id, projectId, title, status, priority, tags, blocked, blockedReason, archived }]
const task = await octopus.tasks.create({ title, body: "Markdown", projectId, priority: 2, tags: ["logs"] });
await octopus.tasks.update(task.id, { status: "doing", blocked: true, blockedReason: "Waiting for QA" });
await octopus.tasks.update(task.id, { status: "done", archived: true });
await octopus.tasks.reveal(task.id);                    // shows it on its board

status is todo, doing, review or done; priority goes from 0 (low) to 3 (urgent). Without projectId, list returns every project's tasks. update changes only what you give it (title, body, status, priority, tags, blocked and blockedReason, archived), and only a done task can be archived.

Git

Open a branch's review in the project's Git tab: everything it changes against the branch it goes into, as a merge request shows it. No permission needed, since only you see the code. By default: the project you're working on, its checked-out branch and the remote's default branch.

const { branch, target } = await octopus.git.review({ projectId, branch: "feature/dark-mode", target: "main" });

Terminals

const terms = await octopus.terminals.list();
// [{ id, projectId, taskId, title, kind: "claude" | "shell", state, exitCode, startedAt, lastOutputAt, floating }]
const text = await octopus.terminals.read(terms[0].id, { lines: 300 });
octopus.on("terminals", (list) => {});   // opened, closed, or changed state
await octopus.terminals.reveal(id);          // shows it in its project's Terminals, focused

state is working, idle, waiting (it's waiting for you) or ended.

Claude in your page

A Claude session your extension opened can be shown right inside its page: Octopus draws its own terminal over an element of yours and follows it as it moves, resizes, shows or hides. You type in it as in Terminals; the page still can't. When the page lets it go, the session goes back to its project's Terminals.

const termId = await octopus.claude.open("Review the failing tests", { projectId, background: true });
const view = await octopus.terminals.embed(termId, document.getElementById("term"));
view.focus();
view.dispose();                              // back to Terminals
await octopus.terminals.close(termId);       // ends it: only sessions this extension opened

It works in the extension's section, not in a window of its own.

Documentation

A report and the charts it shows, written in a project's documentation folder (default: the project you're working on). An existing file is kept unless you pass overwrite: true.

const path = await octopus.docs.write("reports/2026-10-checkout.md", markdown, { projectId });
await octopus.docs.write("reports/volume.svg", svg, { projectId });
await octopus.docs.open(path, { projectId });   // in the Documentation tab

Storage and sharing

await octopus.storage.set("savedQueries", [...]);        // JSON, kept by Octopus
const saved = await octopus.storage.get("savedQueries");  // null if never set

// Publish data for other extensions, and read theirs (permissions.shared).
await octopus.shared.set("events", [...]);
const events = await octopus.shared.get("calendar", "events");
octopus.on("shared", ({ extension, key }) => {});
const exts = await octopus.extensions.list();    // no permission needed

Small things

octopus.toast("Saved", "success");         // info, success, error
octopus.copy(text);                        // to the clipboard
octopus.openUrl("https://…");              // in the browser
octopus.on("theme", (theme) => {});        // colours already applied
octopus.on("visible", (shown) => {});      // the section was shown or hidden

A website as an extension

To bring a site itself into Octopus (Slack, WhatsApp Web, your dashboards) instead of a page of your own, set permissions.webapp to its address. Octopus shows it in a view of its own, keeps you signed in, and counts what's unread from its title.

{
  "name": "Chat",
  "icon": "comment",
  "permissions": {
    "webapp": [
      { "id": "slack", "name": "Slack", "url": "https://app.slack.com/client", "color": "#4A154B" },
      { "id": "telegram", "name": "Telegram", "url": "https://web.telegram.org/a/", "color": "#229ED9" }
    ]
  }
}

A single site can be a plain address: "webapp": "https://web.whatsapp.com". With several, you pin the ones you use as tabs. Add "userAgent": "chrome" for a site that says Safari isn't supported. The site can't reach Octopus, and Octopus can't read the site's content.

Marketplaces

A marketplace is any git repository, or a folder on your Mac, holding extensions. You add it once, install an extension in one click, and see updates when a new version is published. Add as many as you like: public ones for extensions anyone can use, and private ones for your team's own tools, which never leave them.

Make a marketplace

A repository with a folder per extension under extensions/ already works. To give it a name and choose the folders, add octopus-marketplace.json at its root:

octopus-marketplace.json
{
  "name": "team",
  "description": "The checkout team's tools",
  "extensions": [{ "path": "extensions/standup" }, { "path": "extensions/log-analyzer" }]
}

A README.md in an extension's folder is shown on its page in the marketplace.

Push it wherever fits who should see it: a public repository on GitHub for everyone, or a private one on GitHub or your company's GitLab for your team. Octopus works with both the same way.

Add a marketplace

Octopus comes with one already: its own public marketplace (Calendar, Daily, Messaging, JSON Explorer, Pets), added the first time it opens. Remove it if you don't want it: it doesn't come back. To add another:

  1. Open the Extensions section and go to Marketplaces.
  2. Paste the repository's git address and press Add: https://github.com/acme/octopus-extensions.git for a public one, git@gitlab.example.com:team/octopus-extensions.git for a private one. Or press Choose a folder… to use a folder on your Mac as it is.
  3. Octopus clones it and lists its extensions, each with the marketplace it comes from. Add a public marketplace and your team's private one side by side: they stay separate, and each extension updates only from its own.

Private repositories use the git access you already have: your SSH key or the credentials git remembers for that host. Octopus never asks for a password, so if git clone works in your terminal, adding it works too. A folder is read in place, and if it's a git working copy, Refresh only fast-forwards it, never merging over your changes.

Refresh on a marketplace pulls what's been published since; Remove forgets it, leaving the extensions you installed from it in place.

Publish

  1. Add the marketplace, if you haven't yet (see Add a marketplace). To publish you need to be able to push to it.
  2. Open your extension's details and press Publish…. Pick the marketplace (a private one for your team's tools, a public one to share with everyone) and the new version.
  3. Octopus commits it in a temporary worktree, so your own checkout is never touched, and pushes a branch extension/<id>-<version> for review (a merge request on GitLab, the compare page on GitHub), or straight onto the branch.

Install and update

Teammates add the same marketplace and press Install. Check for updates pulls every marketplace and says what's new; a higher version shows up as an update. Editing an installed extension makes it your own copy.

Extensions that need others

An extension can build on another: a standup that reads the meetings a calendar extension shares, say. Name them in dependencies, each with the oldest version that will do:

manifest.json
{
  "name": "Standup",
  "permissions": { "shared": ["calendar"] },
  "dependencies": { "calendar": "1.2.0", "jira": "*" }
}

"*" takes any version. A plain list of ids works too, ["calendar", "jira"], and so does [{ "id": "calendar", "version": "1.2.0" }].

  • Installing. Octopus asks once, listing what it will install, update or turn on first, looking in the extension's own marketplace before the others. Then it installs them in order, and the extension last. If one can't be found, or no version is new enough, it says so before anything changes.
  • In its details. An extension shows what it needs and which installed extensions need it.
  • Uninstalling or turning one off. If others need it, Octopus says which before it goes.

A dependency can still be missing or turned off, for example in an extension you copied by hand. Check octopus.extensions.list() and tell the user what's needed rather than failing silently.

Rules and limits

  • No build step. HTML, CSS and ES modules loaded as they are. No npm, no bundler.
  • No CDN. The page runs under a Content Security Policy that allows only its own files. A library is fine as a file copied into the folder, like vendor/chart.umd.js.
  • No network but octopus.http.fetch. A plain fetch() to another host fails, and so do remote images: fetch them with binary: true and use dataUrl().
  • Only its own folder. An extension never changes Octopus itself: the SDK is all it gets.
  • Storage that lasts. localStorage works, but octopus.storage is the one Octopus keeps.

Ready to build yours?

Download Octopus, open Extensions and press Create with Claude, or start from the Standup tutorial above.

Download Octopus