Get started free
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

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
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
{
    "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
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
<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
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
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
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/workspace-extensions/ and it runs from where it sits. No repo, no commit, no sha, no install dialog.

Inside your sandbox
.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.

  • 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 api object 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.

Type to search every page, in the docs and the API reference.