Host API
The same daemon, from inside the app. An extension's activate() is handed one IntenticApi object — the authenticated transport, the workspace facts, and every surface of the shell it may contribute to.
On this page (13 sections)
- Getting the handle
- api.sandbox — talking to the daemon
- api.workspace — repos, files and diffs
- api.views — the sidebar surfaces
- api.viewers and api.documents
- api.commands and api.settings
- api.processes, api.terminal and api.chat
- api.models — which model a run will spend
- api.navigate, api.route and api.theme
- The backend half — activateServer()
- Versioning
- The packages
- Next
There is no ambient global and no client you construct. The API arrives as the argument to
activate(), everything you register comes back as a disposable, and every door to the daemon on it is gated by the
route allowlist in your manifest. If you are writing your first extension, start with
Build an extension — this page is the reference it links back to.
Getting the handle
import type { ExtensionContext, IntenticApi } from "@intentic/extension-api";
import { bindHost } from "./host.js";
export const activate = (api: IntenticApi, context: ExtensionContext): void => {
bindHost(api);
context.subscriptions.push(
api.views.register({ /* … */ }),
api.commands.register(`incidents.acknowledge`, () => { /* … */ }),
);
};
Views and composables that render later need the same handle, and passing it down through props is not what anyone wants to write.
hostSlot() gives your extension its own module-scoped accessor:
import { hostSlot } from "@intentic/extension-api";
// One slot per extension, bound by activate() before anything renders. The shell publishes ONE copy of the
// API module to every bundle, so a handle held at module scope would be a global the last extension to
// activate silently takes over.
export const { bindHost, host } = hostSlot(`ext-incidents`);
Push every registration onto context.subscriptions. They are disposed in reverse order when the extension is switched
off, which is what makes turning it off actually unwind it rather than leave tiles behind.
api.sandbox — talking to the daemon
Auth is injected host-side; an extension never sees a token. Reach is scoped: every door here is checked against your manifest's
permissions.sandbox allowlist, so a call to an undeclared method and path throws rather than reaching the whole daemon.
| Member | What it does |
|---|---|
rpc | The door to reach for. The daemon's whole contract as a typed client, so a call names a procedure
instead of building a URL: rpc.git.log({ repo, limit }) carries the declared input shape and
answers the declared output shape, both checked when you build.
|
json<T>(path, init?) | A path-based call, parsed as JSON. The escaping, the query encoding and the shape of the answer are all yours to get right — prefer rpc. |
request(path, init?) | The raw Response, for the byte routes and the streams. |
key(...parts) | A cache key scoped to the active sandbox. The required prefix for every query key you write, so caches never bleed across a sandbox switch. |
reachable() | Whether the sandbox is answering. Reactive inside a computed, so it drives a query's enabled. |
origin() | The daemon's public origin, for building an externally shareable URL like a webhook endpoint. Not needed for the calls above. |
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
import { host } from "./host.js";
const api = host();
const query = useQuery({
// Scoped to the ACTIVE sandbox, so a switch can't serve you another box's data.
queryKey: computed(() => api.sandbox.key(`incidents`, `log`, repo.value)),
// A procedure, not a URL: the input shape and the answer's shape are both checked at build time.
queryFn: () => api.sandbox.rpc.git.log({ repo: repo.value, limit: 300 }),
// A sleeping sandbox produces no wall of errors.
enabled: computed(() => api.sandbox.reachable()),
});
You get the shell's own Vue and vue-query instances, so your components join the app's single query cache and re-theme with it. The
routes behind rpc are the ones on the HTTP API page.
api.workspace — repos, files and diffs
| Member | What it does |
|---|---|
repos(), capabilities() | The detection facts: what each repo under /work contains, and which integrations the owner connected. Reactive. |
onDidChange(listener) | Those facts changed. |
onDidChangeRefs(listener) | A git ref moved in one of these repos — a commit, a branch, a checkout, a rebase. The one signal no file watcher can give you, and the one that matters for work the user did not do. |
file(path), readJson<T>(path) | Read a workspace file, or read it as a JSON object. Both answer undefined when it is absent — for readJson, also when it is truncated or hand-mangled, because one bad file must never blank the surface reading it. |
write(path, body) | Create or replace a workspace file. Throws on failure, unlike the reads: a write that silently did nothing would lose what the caller was told was saved. |
openDiff(payload), fillDiff(payload) | Open a diff in the shell's editor tab strip, beside the files it is about. Open it pending and fill it later when the content is slow to compute. |
Keep durable state in the workspace rather than in settings. It survives a reload, it is shared across the owner's browsers, and the
agent writing into it out of band is usually the whole point. The file routes still need declaring —
api.workspace.file removes the encoding, not the grant.
api.views — the sidebar surfaces
A view registers once and produces one activation per sidebar element, decided by detect() against the
facts. Activate on evidence, not names: "this repo has a vitest config" survives a rename, "this repo is called api" does not.
| Field | Meaning |
|---|---|
id, label, surface | Must match a contributes.views entry in the approved manifest, or the registration is refused. Surfaces: rail, directory, sandbox. |
detect(repos, capabilities) | Returns one activation per element: a stable key (the route segment), a title, an optional icon, the repo it is rooted at, and any extra props. Called on every facts poll; a throwing detect just contributes nothing that round. |
badge(activation) | What the tile says without being opened. Needs "badge": true in the manifest. |
fallback | This view's activations are dropped for repos a non-fallback view already claimed. |
auxiliary | Adds a surface beside whatever else serves the repo instead of claiming it — a test runner, a docs browser. |
view() | Lazily imported root component, rendered with repo and your props bound. |
// Module state, not view state: a badge you only see once you have already navigated to the view is
// pointless, so the count has to keep working while the view is unmounted.
let openCount = 0;
api.views.register({
id: `incidents`,
label: `Incidents`,
surface: `rail`,
detect: (repos) => repos.filter((repo) => repo.vitest).map((repo) => ({ key: repo.repo, title: repo.repo, repo: repo.repo })),
badge: () => (openCount > 0 ? { count: openCount, tone: `warning`, tooltip: `${openCount} need triage` } : undefined),
view: async () => (await import(`./IncidentsView.vue`)).default,
});
A badge is a claim on someone's attention, so the bar is high: it must mean "something happened here you don't know about", never
"here is a statistic". A count lit most of the day teaches people to stop seeing the rail. count is for work whose
size is what you act on; mark is a glyph for a pending action where the size changes nothing;
tone is info (the resting tone), warning (a risk being carried) or danger
(something is broken — stay sparing, its value is that it is rare).
badge is read inside the host's own computed and runs on every render of every surface that draws tiles, so it must be
cheap and pure: derive it from state you already keep, never fetch.
api.viewers and api.documents
| Member | What it does |
|---|---|
viewers.register({ id, component }) | A renderer for a file type. The host resolves the open file, fetches it the way your manifest asked (text, blob or a streaming URL) and renders your component with it. You only render. |
documents.register({ id, detect, view }) |
"There is something to read about this directory." Path-keyed rather than repo-keyed, which is the whole
reason it isn't a view: a monorepo is one repo with fifty documented packages. detect(path) returns the
row's icon, tooltip and tab title, or nothing.
|
documents.open(id, path) | Open one of your own documents as if its row icon had been clicked — for the directories with no row, like the workspace root. |
documents.detect is called for every visible directory row on every render of the tree. Like badge, it
must be a lookup and never a fetch, and the state it reads has to outlive the view being unmounted — so it belongs in module state
owned by activate().
api.commands and api.settings
| Member | What it does |
|---|---|
commands.register(command, handler) | Must match a contributes.commands entry. That is also where its palette title and any global shortcut are declared, because a shortcut is consequential enough for the owner to have approved it. |
commands.execute(command, ...args) | Run one, yours or another extension's. |
settings.get(key), set(key, value) | Your own declared settings — string, number or boolean. Persisted daemon-side, keyed by publisher.name, so they survive an update, a remove and re-add, or a re-clone. |
settings.onDidChange(listener) | Someone changed one, possibly in another browser. |
api.processes, api.terminal and api.chat
| Member | What it does |
|---|---|
processes.status/start/stop(name) | Your own declared background processes. A name the manifest never declared is refused. |
terminal.open(session) | Aim the shell's one terminal panel at a tmux session and focus it. setOpen(open) shows or hides the panel without focusing anything. |
chat.openSession(sessionId) | Open the tab for a stored conversation — for a run history or an audit row, where "why did it do that" is only answerable by reading the transcript. |
chat.composeWorkflow(workflowId) | Open a new chat aimed at a workflow, so the next message the user types becomes that run's request. It hands over the start of the work rather than performing it, which is how a Run button avoids becoming a second way to begin agent work with its own dialog that looks like nothing else in the product. |
api.models — which model a run will spend
The picker is not a widget: it is a live read of every connected provider's catalog, which credentials the sandbox actually holds, and what each model can do. An extension that rendered its own control could only offer a worse list — and would happily offer models the sandbox has no credential for, which is a run that fails minutes later.
| Member | What it does |
|---|---|
agentRun() | What a run opens on when nobody has chosen: the sandbox's Agent-runs model, falling back to the owner's own chat setting. Reactive. |
describe(selection) | Name a selection you already hold — a pin read back from disk arrives as bare ids and has to be rendered before anyone opens the picker. Reactive, so a disconnected account changes what a stored pin says about itself. |
pick({ anchor, ... }) | Open the picker over an element — a popover on desktop, a sheet on mobile. Resolves with the pick, or undefined if dismissed. |
A PickedModel covers the whole choice: provider, model, a display label, and
optionally which connected account and which harness. The last two are pins, and absent — "whatever the
daemon resolves" — is the state most callers want, because it is what keeps a saved choice working after an account is disconnected.
Pin the account when the surface starts unattended runs: nobody is watching at 6am, and a first account that has run out of
headroom is a run that errors every time until someone reads the row.
api.navigate, api.route and api.theme
| Member | What it does |
|---|---|
navigate(path) | Send the shell to an app path, like /capabilities. |
route.query() | The current URL query, flattened. Reactive: read it inside a computed and your view re-renders when the URL moves. |
route.setQuery(patch, { push }) | Merge a patch in; a key set to undefined is removed. Replaces the history entry by default, pushes when asked. |
theme.mode(), onDidChange | light or dark, for the rare case CSS variables can't cover — a canvas, a chart palette. |
A view's own route space is the query, not the path: /ext/:ext/:key? is the whole route and :key already
means "which activation". So a view with internal navigation — an open document, a selected run — puts that state in the query,
which is what lets a reload keep it and a link carry it. Derive from the URL rather than mirroring it into a ref; back and forward
then work for free.
The backend half — activateServer()
An extension with a manifest server entry has a second, smaller surface. The bundle exports
activateServer(api, context), and the daemon's backend host — one supervised node process shared by every enabled
backend — imports it and calls it once per host start. There is no deactivate: retirement is the host process ending, which is the
one teardown that cannot leak.
| Member | What it does |
|---|---|
routes.mount(handler) | Serve your extension's own /x/<id>/… namespace. The daemon proxies requests here through its ordinary auth, with the prefix already stripped, so your handler sees your own paths and never an unauthenticated request. Return undefined for "not mine" and the host answers 404 for you. A second mount replaces the first. |
daemon.request(path), daemon.json(path) | The backend's authenticated transport into the daemon's own routes — its api.sandbox. Auth is a minted per-extension token injected here, and every call is checked against the manifest's permissions.daemon allowlist. |
workspaceRoot | The workspace root, absolute. The backend is full-trust code in the sandbox, so files are plain node:fs under this path — no file service in between. Durable state belongs in workspace files, same rule as the UI half. |
extensionDir | Your own checkout, absolute — where your bundled assets sit. |
log(message) | A line in the daemon's log, attributed to your extension. |
context.extensionId | Your routing id — the /x/<id> segment your UI half calls. |
The surface is deliberately small because the trust model is full trust: installs are owner-only and sha-pinned, and the backend runs as the same user the daemon does. What the API mediates is exactly the two things a path cannot carry — your route namespace, and your declared reach into the daemon.
Versioning
api.apiVersion is the host's extension-API version, and it is what your manifest's engines.intentic range
is checked against before your code is loaded. Current API: 2.1.0. Additive surface is a minor bump,
breaking is a major — so "^2.0.0" is the range you want, or "^2.1.0" if you ship a backend.
The packages
| Package | Holds |
|---|---|
@intentic/extension-api | The one you program against: the IntenticApi types, the detection facts, the diff payload, hostSlot, and the SSE reader. |
@intentic/extension-manifest | The manifest schema and the route-permission rule, so your manifest is validated where you write it rather than at install. |
@intentic/sandbox-contract | The daemon's own wire contract. What api.sandbox.rpc is typed against, and where every request and response shape is declared. |
@intentic/extension-ui | The shell's own buttons, inputs, cards and icons. Resolved by the host at runtime; mark it external. |
Mark all of them external when you bundle, along with vue and @tanstack/vue-query. The host supplies them
at runtime, and bundling your own copy forks reactivity and the query cache.
Next
- HTTP API: the routes behind
api.sandbox.rpc, and how to call them without a browser. - Build an extension: the same surface as a walkthrough, ending in a real install.
- Manifest reference: every contribution point these registrations have to match.