---
title: "Build an extension · intentic docs"
description: "Author an intentic extension end to end: the manifest, activate(), a rail view that reads from the daemon, and a sha-pinned install."
url: "https://intentic.dev/docs/extensions/build/"
updated: "2026-08-04"
---

Documentation

# 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.

## 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": "^0.4.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.

## Developing without a rebuild loop

Point the install at a branch head sha, iterate, and re-add at the new sha to pick up changes. The staged-checkout validation makes that cheap and safe. Your extension's settings are stored by `publisher.name` on the daemon, not in the checkout, so they survive every re-clone.

## Next

- [Manifest reference](https://intentic.dev/docs/extensions/manifest/): the contribution points this page didn't use, capability cards, processes, listeners, agent plugins, image fragments.
- [Publish & the marketplace](https://intentic.dev/docs/extensions/publish/): list it so other people can install it in one click.

[Previous ← Extensions](https://intentic.dev/docs/extensions/) [Next Manifest reference →](https://intentic.dev/docs/extensions/manifest/)
