DorkOS
Integrations

Extensions

Subscribe to privacy-safe chat activity events from a DorkOS extension

Extensions

Extensions let you build custom UI and automation on top of DorkOS chat activity, without ever seeing what was said in a chat. This page documents the event-subscription surface, api.events, and assumes you already have an extension scaffold (a manifest and an activate() entry point) to build on.

A DorkOS extension is a bundle of JavaScript that runs in the browser alongside the client UI. Its manifest (extension.json) declares identity, version, and capabilities; its entry point exports an activate(api) function that the host calls on load, handing it an ExtensionAPI object:

import type { ExtensionAPI } from '@dorkos/extension-api';

export function activate(api: ExtensionAPI): () => void {
  // register UI, read state, subscribe to events...
  return () => {
    // cleanup, called on deactivate
  };
}

ExtensionAPI covers UI contributions (registerComponent, registerCommand, registerDialog, registerSettingsTab, full pages at /x/<your-id>/… with registerPage, and chat status-bar items with registerStatusBarItem), UI control (executeCommand, openCanvas, navigate, a dot on your own right-panel tab with setTabMarker, and a status or count on your own page's tab with setPageBadge, which older DorkOS versions lack, so check typeof api.setPageBadge === 'function' first), read-only host state (getState, subscribe, including currentProject and requireLogin, the project the selected folder belongs to), scoped storage (loadData/saveData), notifications (notify), your extension's inbox questions and per-project settings (listDecisions, answerDecision, projectSettings), starting work in a new chat from a button (startWork), reading and replying in a room thread as you (api.rooms), and the event subscription surface this page documents: api.events. See packages/extension-api/src/extension-api.ts for the full interface; this page focuses on api.events.subscribe. An extension with a server half can also ask you things in the Activity inbox (ctx.inbox), keep a route to people only (ctx.requirePerson), read settings only you can change (ctx.projectSettings), start work in a new chat by its own rules (ctx.sessions), and send one of your agents a message (ctx.agent). A chat started either way says so as its first line, "Started by the My Extension extension: …"; see contributing/extension-authoring.md.

Your extension's own API

An extension with a server half can answer web requests at /api/ext/<your-id>/. Its page calls them with fetch('/api/ext/my-extension/status'). You can write that API in two ways.

  • With the router. register(router, ctx) gets an Express router. Add routes to it: router.get('/status', …) answers GET /api/ext/my-extension/status.
  • With a fetch handler. Return { fetch } from register. fetch takes a standard web Request and returns a Response. Any handler of that shape, such as a Hono app, works as it is: return { fetch: app.fetch }. Hono is built in, so import { Hono } from 'hono' works with nothing installed. It runs inside DorkOS on Node, so code that needs Bun or Deno's own features does not. If your server half needs nothing from ctx, export the app itself with export default app.
server.ts
import { Hono } from 'hono';
import { requirePerson } from '@dorkos/extension-api/server';

export default function register(router, ctx) {
  const app = new Hono();
  app.get('/status', async (c) => c.json(await ctx.storage.loadData()));
  app.put('/settings', requirePerson(), async (c) => {
    await ctx.storage.saveData(await c.req.json());
    return c.json({ ok: true });
  });
  return { fetch: app.fetch };
}

What to know about a fetch handler:

  • It sees the path after your mount. A request to /api/ext/my-extension/status?x=1 reaches it as /status?x=1.
  • It answers every request it gets. Return your own 404 for a path you don't serve (a Hono app does this for you). If you also add router routes, the router answers first and the fetch handler gets the rest.
  • A JSON body is ready to read with request.json(). Any other body arrives as it was sent.
  • It knows who is asking. Its second argument says whether a person sent the request. A Hono app reads it as c.env.
  • To keep a route to people only, put requirePerson() in front of it, as above. Agents and other sites get a plain refusal, and the route never runs. A handler written without Hono calls checkPerson(caller) from @dorkos/extension-api/server and returns the answer it gives back, if any.
  • Some reply headers are dropped. It can't set cookies, and a page it serves can't run scripts. Content and caching headers, and your own x- headers, go through.
  • Redirects stay inside your extension. c.redirect('/done') sends the browser to /api/ext/<your-id>/done. A redirect to another website is dropped.
  • If it throws, the caller gets an error and DorkOS keeps running.

Nothing in your API runs until you approve the extension, and turning it off stops the API at once.

Discussing an item in a room thread

A mini app can show a room thread on its own page and let you reply there, so you can talk about a post or a task without leaving it. It needs "capabilities": { "rooms": true } in its manifest.

  • You choose the rooms. In Settings → Extensions, the mini app's card lists Rooms it can use. A well-behaved mini app reads and replies only there, and only where you can read yourself. Replacing a mini app with another copy clears its rooms.
  • Replies are yours. A reply sent from the mini app shows under your name, the same as one you typed in the room, and mentions wake agents as usual. The audit trail records each one with the mini app named.
  • Agents can't reply as you through it. An agent that names itself, another website and anything relayed are refused.
  • It's not a sandbox. A mini app's page runs in your window with your access, so it can reply without a click and change its own room list. Its server half, if it runs inside DorkOS, can do anything DorkOS can. With no password on this computer, a program on it can act as you too. Only turn on mini apps you trust, and turn on "Ask for a password on this computer too" to close the last gap.
  • It keeps track of what you've read, per thread, and shows new replies as they arrive.

Authors: see api.rooms in contributing/extension-authoring.md.

Messaging an agent from the server half

ctx.agent.send({ to, text, context?, idempotencyKey }) sends one of your agents a message from an extension's server half. to is an agent id or a chat id. A message to an agent goes to the one chat that extension keeps with it. A chat id must be one of your own chats, or one that extension started; another extension's chats, rooms, bridged chats and scheduled runs are refused.

  • It answers at once with a receipt: { messageId, status, reason?, sessionId }. status is started when the agent took it straight away, or queued when it is waiting. reason says why it waits: busy (the agent is mid-turn) or at_capacity (too much is running; DorkOS keeps it and sends it as soon as there is room). A message waiting for room is not in the chat's queue yet, so you won't see it there. If the extension stops first, the message is dropped and the extension is told.
  • A busy agent holds the message, with no time limit. It waits in the chat's queue, where you can see, edit or remove it, and runs when the current turn ends. It survives a restart.
  • The extension hears what happened through ctx.agent.subscribe: turn.started when the agent starts on it, then turn.done, or turn.failed with a reason (removed, session_gone, interrupted, undeliverable, stopped) when it will never run. One case has no event: a message whose turn keeps failing to start stays in the queue and is tried again until it runs or you remove it.
  • Sending again with the same key does nothing new. It answers with the first receipt.
  • The agent reads it as app data. The words arrive inside a marked block that says which app sent them and that they are data, not instructions. The extension cannot choose the folder, the permission level or which agent the turn acts as.

Giving your agents tools

An extension with a server half can give your agents tools while it runs. A mail extension, for example, can offer a "Send an email" tool, and your agents can call it the way they call any DorkOS tool.

  • The extension is approved first. One your agent builds is approved as it is made, unless you chose Careful; any other waits for you. Its tools exist only while the extension is turned on and approved to run. Turn it off, stop it or remove it, and its tools are gone from the next tool list.
  • You see what it gives before you say yes. The Activity inbox row that asks you to turn an extension on says what it would give your agents, such as "Would give agents 3 tools and 1 skill". Open it with ⓘ to see each tool and what it can do: Reads, Acts, or Asks you first. Its card in Settings → Extensions shows the same list. It says "Gives agents" while the extension runs and "Would give agents" while it is off or waiting for you.
  • A tool or skill DorkOS turns down says why. If a tool or skill is set up wrong, it is listed as left out, with the reason under it, and it never reaches your agents. The extension's other tools and skills still work.
  • Each tool says how much it can do. A tool that only reads runs when an agent calls it. A tool that changes something sits in the Extension tools area of Settings → Permissions, where you can set every extension's tools, or one tool, to Allowed, Ask or Blocked. A tool that deletes or removes something asks you every time, even if you set it to Allowed.
  • Each tool names its extension. Its row on the permissions page and its approval card say which extension it came from, as "From Mail".
  • Block them in one place. In Settings → Permissions, set the Extension tools area to Blocked to stop every extension's tools at once, or open Show individual actions to block one tool.
  • Removing an extension clears its tool settings. If you set one of its tools to Allowed, that choice does not carry over to anything installed later under the same name.
  • When agents see a new tool. Codex and OpenCode chats get the new list the next time they ask DorkOS for it. A Claude Code chat gets it with your next message. A reply that is already being written finishes with the tools it started with, and a chat whose helper is still working gets the new list once that work ends.

Skills from extensions

An extension can also bring skills: short guides that teach your agents when and how to use it. A mail extension might bring a "triage inbox" skill that tells an agent how to sort your mail with its tools.

  • They follow the same approval. An extension's skills reach your agents only while it is turned on and approved to run. Turn it off, stop it or remove it, and its skills go away too.
  • They land where a plugin's skills land. A project's own extension adds its skills to that project, as .claude/skills/<extension>__<skill> and .agents/skills/<extension>__<skill>, so Claude Code, Codex and the other tools DorkOS sets up there all find them. An extension installed for all your projects reaches DorkOS's own Claude Code chats right away, and reaches other tools once you have chosen to share with them and run dorkos harness sync --global.
  • The same answer from the terminal. dorkos harness sync reads the same list DorkOS keeps of running extensions' skills, so it adds the same skills and never removes one that should be there.
  • A plugin's skill wins a name clash. If a plugin and an extension bring a skill with the same name, your agents get the plugin's, and dorkos harness sync --check says which one was left out.
  • A chat that is busy finishes first. When an extension stops, a Claude Code chat drops its skills before your next message, unless a helper in that chat is still working. Then it waits for that work to end.

For the manifest fields, the ctx.tools.handle API and how skills are checked, see contributing/extension-authoring.md.

Where a tab shows

An extension's right-panel tab can show everywhere, or only beside the agents, channels and pages it belongs to. You decide where:

  • In the panel. Open the panel's ⋯ menu. Hide here hides the tab in this channel, in chats with this agent, or on this page (Home counts as #team). Show here brings back a tab that is hidden here. Open full page goes to the extension's own page, when it has one. Hide here and Show here are not offered in a chat with no agent, or in a channel from a connected space, because there is nothing narrower to name there than every chat or every channel.
  • In Settings. Open Settings → Extensions. Under each extension with a tab, Panel tab shows has two choices: Everywhere, or Only in… a list of agents, channels and pages you pick. Where a tab shows everywhere but was hidden in some places, those places are listed under Hidden in, and you can remove them there.

An extension can say where its tab belongs by default, with showIn on registerComponent. Your choice replaces that default, and Use its default puts it back. Your choice applies to everyone who uses this DorkOS, and an agent can change it too. The saved list is ui.panelTabs in your settings file. For the details, see contributing/extension-authoring.md.

Testing locally

The host discovers extensions from two directories: .dork/extensions/<id>/ in your project for a local extension, or ~/.dork/extensions/<id>/ for a global one available everywhere. It also loads extensions that come inside an installed marketplace plugin, once you approve them. Drop your manifest and entry file there, then open Settings → Extensions and click Reload Extensions to pick up the change without restarting the client.

A newly installed extension also shows up in the Activity inbox (the bell at the top right) with one short row: "Turn on My Extension?", a line saying what it adds, and three buttons. Turn it on starts it straight away, with no reload. Not now turns nothing off and removes nothing; the inbox just stops asking until the extension changes version or moves. The line under the question comes from your manifest: list what you add in contributions (for example "right-panel": true) and say what it is for in purpose, a short phrase that finishes the sentence "This adds a My Extension tab that …".

Event subscription

api.events is a small, typed push channel. It is deliberately not the raw chat stream. Extensions run untrusted in the browser, so the host exposes only a curated set of lifecycle and activity summaries:

interface ExtensionEventsAPI {
  subscribe(kinds: ExtensionEventKind[], handler: (event: ExtensionEvent) => void): () => void;
}

Call api.events.subscribe with the event kinds you want and a handler; it returns an unsubscribe function (also called automatically on deactivate).

Privacy guarantee. Events never carry chat content. Excluded, forever: assistant/user message text, thinking output, tool-call arguments, tool results, and relay message payloads. An extension learns that a tool ran and which tool, never what it did.

Event kinds and payloads

Prop

Type

Manifest declaration

An extension must declare which events it wants to receive in the manifest's capabilities.events array before api.events.subscribe will deliver anything. Each entry is either a specific kind ('turn.completed') or a whole category ('session', 'turn', 'tool', 'relay'): declaring a category authorizes every kind under it:

extension.json
{
  "id": "my-extension",
  "name": "My Extension",
  "version": "1.0.0",
  "capabilities": {
    "events": ["session", "tool.activity", "relay.message"]
  }
}

A subscribe call for a kind the manifest didn't declare is rejected at runtime: the host logs a console warning naming the undeclared kind(s) and silently drops them from the subscription (the rest still deliver). If every requested kind is undeclared, the returned unsubscribe function is a no-op. Open your browser's devtools console while testing to catch these warnings.

Event scoping

Delivery scope differs by event category. This matters when you're deciding whether an event tells you about the whole host or just what the operator is currently looking at.

Prop

Type

If you need turn or tool activity for a chat that isn't in the foreground, there is no way to get it through api.events today; those two categories are gated to whatever chat the operator has attached.

Example

A manifest that declares access to chat lifecycle and tool activity, and an activate that logs both:

extension.json
{
  "id": "activity-logger",
  "name": "Activity Logger",
  "version": "1.0.0",
  "description": "Logs session lifecycle and tool activity to the console",
  "capabilities": {
    "events": ["session", "tool.activity"]
  }
}
index.ts
import type { ExtensionAPI, ExtensionEvent } from '@dorkos/extension-api';

export function activate(api: ExtensionAPI): () => void {
  const unsubscribe = api.events.subscribe(
    ['session.started', 'session.ended', 'tool.activity'],
    (event: ExtensionEvent) => {
      switch (event.kind) {
        case 'session.started':
          console.log(`[activity-logger] session started: ${event.sessionId}`);
          break;
        case 'session.ended':
          console.log(`[activity-logger] session ended: ${event.sessionId}`);
          break;
        case 'tool.activity':
          console.log(`[activity-logger] ${event.toolName} ${event.status}`);
          break;
      }
    }
  );

  return () => {
    unsubscribe();
  };
}

Next Steps