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(11 sections)
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.
mkdir acme-incidents && cd acme-incidents
pnpm init
pnpm add -D vite @vitejs/plugin-vue vue typescript
pnpm add @intentic/extension-api @intentic/sandbox-contract @intentic/extension-ui
# pnpm 11 will not run a dependency's install script until you approve it by name, and
# vite's bundler needs its own to put a platform binary in place. Without this, the build
# fails on a missing binary rather than on anything you wrote.
cat > pnpm-workspace.yaml <<'YAML'
allowBuilds:
esbuild: true
YAML@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.
{
"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.
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; "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.
<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.
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 is the shell's own buttons, inputs, cards, rows and icons. Install it for the types and mark it external: the components themselves come from the running app, so your view is built from the same parts the rest of the app is and follows the reader's theme without being told. Marking it external is the tidier route; if you forget, the package's own entry forwards to the host anyway rather than bundling a second, unthemed copy.
6 · Style it
The classes for layout, typography, spacing and colour are provided by the host, and they are a promise rather than a side effect: the whole spacing scale, every step of the type scale and every colour role exist whether or not anything is currently using one. Nothing scans your bundle for class names (nobody builds it but you), so a class only works if the host promised it.
<!-- Layout, type and colour come from the host. Name a ROLE, not a colour: the
theme decides what "danger" or "card" is in light and dark, and your view follows. -->
<div class="flex items-center gap-2 border border-line bg-card px-3 py-2">
<span class="truncate text-sm text-content">{{ incident.title }}</span>
<span class="ml-auto shrink-0 text-2xs text-muted">{{ incident.age }}</span>
</div>
<!-- A pane is not a window: size against the CONTAINER, so a reader who drags your
panel narrow gets the narrow layout. -->
<div class="@container">
<div class="flex flex-col gap-2 @lg:flex-row @lg:items-center"></div>
</div>Two rules follow from that, and they are the whole of it. Name a colour role (text-muted, bg-card, border-line, text-danger) rather than a literal colour, so your view reads correctly in light and dark and picks up the reader's accent. And size against the container, with @container and @lg: rather than lg:, because your view renders into a pane that can be dragged, popped out or stacked under a chat, and the window's width is not the question.
What the promise cannot cover is one-off values: w-[37px], max-w-[64ch], text-[0.65rem]. They are infinite, so no promise reaches them, and they render as nothing. Use the scale, reach for a kit component (Page already owns the reading column, SplitView the index-beside-body layout), or ship the rule in your own stylesheet, added by activate() and removed when your extension is switched off.
7 · Install it
Commit the built bundle. The sha you push is the identity your extension installs under.
pnpm 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 installIn 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:
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 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/config/workspace-extensions/ and it runs from where it sits. No repo, no commit, no sha, no install dialog.
.intentic/config/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 atEverything 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 something you can ask for and have five minutes later: the agent writes files into the workspace and the sandbox picks them up. No publish, no install, nothing to clone.
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.namethat 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;
agentandbinfrom the agent's next turn; anenvironmentfragment 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: the contribution points this page didn't use, capability cards, processes, listeners, agent plugins, image fragments.
- Host API reference: every member of the
apiobject above: the typed daemon client, workspace files and diffs, documents, models, routing. - Publish & registries: list it so other people can install it in one click.