---
title: "Extension host API reference · intentic docs"
description: "Every member of the IntenticApi object — the typed daemon client, views, documents, commands, models, routing — and the backend's activateServer surface."
url: "https://intentic.dev/docs/sandbox-api/host/"
updated: "2026-08-10"
---

Build on it

# 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](#getting-the-handle)
- [api.sandbox — talking to the daemon](#api-sandbox-talking-to-the-daemon)
- [api.workspace — repos, files and diffs](#api-workspace-repos-files-and-diffs)
- [api.views — the sidebar surfaces](#api-views-the-sidebar-surfaces)
- [api.viewers and api.documents](#api-viewers-and-api-documents)
- [api.commands and api.settings](#api-commands-and-api-settings)
- [api.processes, api.terminal and api.chat](#api-processes-api-terminal-and-api-chat)
- [api.models — which model a run will spend](#api-models-which-model-a-run-will-spend)
- [api.navigate, api.route and api.theme](#api-navigate-api-route-and-api-theme)
- [The backend half — activateServer()](#backend)
- [Versioning](#versioning)
- [The packages](#the-packages)
- [Next](#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](https://intentic.dev/docs/extensions/build/) — this page is the reference it links back to.

## Getting the handle

src/extension.ts

```typescript
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:

src/host.ts

```typescript
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. |

A typed read, cached correctly

```typescript
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](https://intentic.dev/docs/sandbox-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. |

A rail view with a badge

```typescript
// 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](https://intentic.dev/docs/sandbox-api/): the routes behind `api.sandbox.rpc`, and how to call them without a browser.
- [Build an extension](https://intentic.dev/docs/extensions/build/): the same surface as a walkthrough, ending in a real install.
- [Manifest reference](https://intentic.dev/docs/extensions/manifest/): every contribution point these registrations have to match.

More in Build on it

[Previous ← HTTP API](https://intentic.dev/docs/sandbox-api/)
