---
title: "Build an extension · intentic API"
description: "Build an intentic extension from manifest to rail view, then install it by pinned commit or run it straight from your workspace."
url: "https://intentic.dev/api/build/"
updated: "2026-08-12"
---

Build

# Build an extension

A rail view that reads from the daemon, badges when something needs attention, and installs into a real sandbox, start to finish.

**On this page (10 sections)**

- [1 · Start a package](#1-start-a-package)
- [2 · Declare what it contributes](#2-declare-what-it-contributes)
- [3 · Write activate()](#3-write-activate)
- [4 · Render the view](#4-render-the-view)
- [5 · Bundle it](#5-bundle-it)
- [6 · Install it](#6-install-it)
- [Optional: give it a backend](#optional-give-it-a-backend)
- [Skip all of that: write it in the workspace](#workspace)
- [The loop](#the-loop)
- [Graduating it](#graduating-it)

## 1 · Start a package

An extension is an ordinary npm package that happens to hold a manifest. Nothing about the layout is prescribed except where the manifest lives: the repo root.

Your machine

```bash
mkdir acme-incidents && cd acme-incidents
npm init -y
npm i -D vite @vitejs/plugin-vue vue typescript
npm i @intentic/extension-api @intentic/sandbox-contract
```

`@intentic/extension-api` is the one SDK you program against: the manifest schema, the host API types, and the detection facts. `@intentic/sandbox-contract` is optional. It holds the zod schemas for the daemon routes you call, so your responses are typed and validated instead of cast.

## 2 · Declare what it contributes

Write the manifest before the code. It's the approval dialog the owner reads, and the host enforces it at runtime: a registration that isn't declared here is refused, so this file is the honest description of what your extension can do.

intentic-extension.json

```json
{
 "publisher": "acme",
 "name": "incidents",
 "version": "1.0.0",
 "category": "work",
 "icon": "exclamation-triangle",
 "engines": { "intentic": "^2.0.0" },
 "entry": "dist/extension.js",
 "permissions": { "sandbox": ["GET /logs"] },
 "contributes": {
 "views": [{ "id": "incidents", "label": "Incidents", "surface": "rail", "badge": true }],
 "settings": [
 { "key": "pageSize", "type": "number", "title": "Rows per page", "default": 50 }
 ]
 }
}
```

Three fields are doing real work. `engines.intentic` is a semver range over the host's API version, checked before your code is loaded. `entry` points at a **prebuilt, committed** bundle. There is no install-time build step, so the commit the owner approves is literally the code that runs. `permissions.sandbox` is the complete list of daemon routes you may call; `*` matches one path segment.

## 3 · Write activate()

There is no ambient global. The host API arrives as the argument to `activate()`, and everything you register comes back as a disposable you push onto `context.subscriptions` so switching the extension off unwinds it cleanly.

src/extension.ts

```typescript
import type { ExtensionContext, IntenticApi } from "@intentic/extension-api";

// Module state, not view state: the badge has to keep working while the view is unmounted.
let openCount = 0;

export const activate = (api: IntenticApi, context: ExtensionContext): void => {
 context.subscriptions.push(
 api.views.register({
 id: `incidents`,
 label: `Incidents`,
 surface: `rail`,
 // Evidence, not identity: activate wherever there is something to show.
 detect: (repos) => repos.filter((repo) => repo.vitest).map((repo) => ({
 key: repo.repo,
 title: repo.repo,
 icon: `exclamation-triangle`,
 repo: repo.repo,
 })),
 badge: () => (openCount > 0 ? { count: openCount, tone: `warning` } : undefined),
 view: async () => (await import(`./IncidentsView.vue`)).default,
 }),
 );
};
```

`detect()` decides when your view appears, and it runs against **facts, not names**: it receives the repos found in the workspace and the capabilities the owner connected, and returns one activation per sidebar element. Activating on "the repo contains a vitest config" survives a rename; activating on "the repo is called api" does not.

A `badge` is the one thing your tile can say without being opened, so treat it as a claim on attention: it must mean "something happened here you don't know about", never "here is a statistic". It also has to be declared with `"badge": true` in the manifest: a tile that can interrupt the user is a contribution the owner approves.

## 4 · Render the view

Views are ordinary Vue components. The host binds `repo` and any props from the activation, and provides its own Vue, vue-query and design-system instances, so your component joins the shell's single query cache and re-themes with it.

src/IncidentsView.vue

```typescript
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { host } from "./host";

// One activation per repo. The host binds `repo` (and any extra props) for you.
const props = defineProps<{ repo: string }>();

const { data } = useQuery({
 // Always prefix with api.sandbox.key(...) so the cache can't bleed across a sandbox switch.
 queryKey: host.sandbox.key(`incidents`, props.repo),
 queryFn: () => host.sandbox.json<{ lines: string[] }>(`/logs`),
 enabled: () => host.sandbox.reachable(),
});
</script>

<template>
 <ul>
 <li v-for="line in data?.lines ?? []" :key="line">{{ line }}</li>
 </ul>
</template>
```

Two rules make caching behave. Prefix every query key with `api.sandbox.key(...)` so a sandbox switch can't serve you another box's data, and gate fetches on `api.sandbox.reachable()` so a sleeping sandbox doesn't produce a wall of errors.

If your view reads workspace files that the agent edits, declare them under `contributes.files`. The daemon watches the filesystem and pushes an invalidation to the query keys you name, which is how you stay live without polling.

## 5 · Bundle it

Ship a **single-file ESM bundle** with the host-provided modules marked external. The host serves your bundle over an authenticated fetch and imports it from a blob URL, so relative chunk imports have no base to resolve against.

vite.config.ts

```typescript
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";

export default defineConfig({
 plugins: [vue()],
 build: {
 outDir: "dist",
 lib: { entry: "src/extension.ts", formats: ["es"], fileName: () => "extension.js" },
 rollupOptions: {
 // The host supplies these at runtime. Bundling your own copy forks reactivity and the query cache.
 external: ["vue", "@tanstack/vue-query", "@intentic/extension-api", "@intentic/extension-ui"],
 // One file, no chunks: the loader imports the bundle from a blob URL, where relative chunks break.
 output: { inlineDynamicImports: true },
 },
 },
});
```

`@intentic/extension-ui`, the shell's own buttons, inputs, cards and icons, is resolved by the host at runtime today; its typed npm artifact lands with the marketplace. Until then you can render with plain Vue and the shell's CSS variables, or mark it external and import it untyped.

## 6 · Install it

Commit the built bundle. The sha you push is the identity your extension installs under.

Your machine

```bash
npm run build # produces dist/extension.js
git add -f dist/extension.js
git commit -m "release 1.0.0"
git push && git rev-parse HEAD # this sha is what you install
```

In the app, go to **Capabilities → Add → Extension** and give it the repo URL and that full commit sha (plus a token for a private repo). The daemon clones into a staging directory, validates the manifest and checks that the entry bundle really exists *before* anything goes live, so a broken push can't replace a working install.

Reload the app and your view is in the rail. Agent-side contributions (`agent`, `bin`) apply from the next turn; an `environment` fragment applies at the next image rebuild.

## Optional: give it a backend

Sometimes the view needs more than the daemon's routes offer: a client for somebody else's API, or work that outlives the tab. Add a second bundle and name it in the manifest as `"server": "dist/server.js"`, with `"engines": { "intentic": "^2.1.0" }`. It exports one function:

src/server.ts

```typescript
import type { ExtensionServerApi, ExtensionServerContext } from "@intentic/extension-api";

export const activateServer = (api: ExtensionServerApi, _context: ExtensionServerContext): void => {
 api.routes.mount(async (request) => {
 const url = new URL(request.url);
 // The daemon proxies /x/<your id>/incidents here, prefix already stripped, auth already checked.
 if (url.pathname === "/incidents") {
 return Response.json({ open: 3 });
 }
 return undefined; // "not mine": the host answers 404 for you
 });
};
```

Your UI half calls its own namespace with `api.sandbox` and no extra permission: the backend is your own code from the same approved checkout. The backend's reach the *other* way, into the daemon's routes via `api.daemon`, is the manifest's `permissions.daemon` allowlist. Bundle the server self-contained: everything except node builtins goes in, because the installed checkout has no `node_modules`. The [Host API page](https://intentic.dev/api/host/#backend) documents the whole server surface.

## Skip all of that: write it in the workspace

Steps 1 to 6 are the path for an extension other people will install. For one that only has to work in *your* sandbox, there is a shorter one: put the directory under `.intentic/workspace-extensions/` and it runs from where it sits. No repo, no commit, no sha, no install dialog.

Inside your sandbox

```bash
.intentic/workspace-extensions/
└── incidents/
 ├── intentic-extension.json # the manifest, at the root of the directory
 ├── dist/extension.js # the entry bundle, if it contributes UI
 └── plugin/skills/... # anything else the manifest points at
```

Everything else stays the same: the manifest, `activate()`, contribution points, and the same row on **Sandbox → Extensions** with the same on/off switch and settings form. A workspace extension is not a lesser kind of extension; it is the same kind that skipped the courier.

This is the path to hand your **agent**, and the reason it exists. "Build me a rail view that lists our incidents" is a thing you can now ask for and have five minutes later: the agent writes files into the workspace and the sandbox picks them up. No publish, no install, and nothing to clone from a machine that doesn't have it yet.

### The loop

- **Edit, then reload.** A workspace extension's identity is its bytes, not a commit, so rebuilding the bundle is the whole update. Press the reload button on the Extensions tab and the new code is running; there is nothing to re-add and no page refresh.
- **Appearing and disappearing are just files.** Creating the directory installs it and deleting the directory removes it. The tab follows the filesystem live.
- **Mistakes are named.** An invalid extension directory appears under *Not loadable* with the reason. This includes a missing or unreadable manifest and a `publisher.name` that something else already owns. Nothing here can shadow a baked or installed extension.
- **The usual timing applies.** Views, viewers, commands, settings and connector cards land at once; `agent` and `bin` from the agent's next turn; an `environment` fragment at the next image rebuild.

### Graduating it

When it turns out to be good, move the directory into a repository, commit the built bundle, and install it the sha-pinned way from step 6. Its identity, its switch and its stored settings all key off `publisher.name` rather than where the code came from, so they survive the move, and every other sandbox can have it too.

## Related pages

- [Manifest reference](https://intentic.dev/api/manifest/): the contribution points this page didn't use, capability cards, processes, listeners, agent plugins, image fragments.
- [Host API reference](https://intentic.dev/api/host/): every member of the `api` object above: the typed daemon client, workspace files and diffs, documents, models, routing.
- [Publish & registries](https://intentic.dev/api/publish/): list it so other people can install it in one click.

More in Build

[Previous ← Overview](https://intentic.dev/api/)[Next Manifest reference →](https://intentic.dev/api/manifest/)
