Skip to content

Building extensions

An extension adds capability to Consortium. A connector gives your agents new tools. A skill gives them new instructions. A view extension adds an interface people can actually use — a whiteboard, a dashboard, a viewer.

This page is about building them, and specifically about building them so that installing yours is a reasonable thing for someone to do.

You write a web app. It ships as a signed bundle. It runs in a sandbox with no network, no keys, and no tokens, and talks to Consortium through a single message channel where every message is checked against permissions the user agreed to at install.

If that sounds restrictive, it is. The restriction is what makes a marketplace of third-party code compatible with a product whose central promise is that we cannot read your data.

Every extension declares where its code runs. This is the first thing reviewers look at, because it determines everything else.

SurfaceRuns onUse it for
remoteYour serversA hosted MCP server. Consortium never runs your code.
viewThe user’s device, sandboxedInterfaces — whiteboards, dashboards, editors.
localThe user’s own machineTools needing local filesystem or process access.
promptNothing — text onlySkills: instructions injected into context.

The rest of this page is about view, because it is the surface where your code runs inside someone else’s session and therefore the one with rules worth understanding.

Four properties hold regardless of what your extension does. They are enforced, not requested.

You run in an opaque origin. Your bundle is loaded into a sandbox with no access to Consortium’s origin, storage, or cookies. You cannot reach the host page’s DOM or JavaScript.

You never receive keys. Not encryption keys, not auth tokens, not account or machine identifiers. Where you need to refer to something, you get an opaque handle. This is deliberate: it means a bug in your extension cannot leak a user’s keys, because you never had them.

The host does all cryptography. You emit document changes in the clear, inside your sandbox; the host seals them before they leave the device. You never implement encryption and you never can get it wrong.

Every message is checked. There is no trusted message type and no ambient authority. Each request is authorized against the capabilities the user granted, rate-limited, and size-capped.

One file, consortium.json, at your bundle root. It is immutable per version and covered by your signature.

{
"manifestVersion": 1,
"id": "com.acme.whiteboard",
"name": "Whiteboard",
"version": "1.2.0",
"surface": "view",
"entry": "index.html",
"icon": "icon.svg",
"capabilities": {
"doc": { "mode": "readwrite", "schema": "acme.whiteboard.v1", "maxBytes": 52428800 },
"presence": true,
"agentTools": [
{ "name": "list_shapes", "description": "List shapes on the board", "input": {} }
],
"export": ["png", "svg"],
"net": []
},
"placement": ["workspace", "org"],
"minHostApi": 1
}

Your id must be a reverse-DNS name on a domain you control and have verified. com.acme.whiteboard requires proving control of acme.com.

Unknown fields are rejected, not ignored. A manifest that doesn’t parse doesn’t install.

Capabilities are what you’re asking the user to agree to. Ask for less and more people will install you.

CapabilityGrants
doc.modeRead or read/write your extension’s collaborative document
doc.maxBytesA storage ceiling — a quota, not authority
presencePublish and receive live cursors
agentTools[]Expose named tools that agents can call
export[]Hand a file to the user through the system save dialog
net[]Outbound HTTP to specific listed origins
org.readMembership and names. Never message or file content.

Leave it empty. An empty net means your extension is structurally incapable of sending data anywhere — enforced twice, by the sandbox’s content policy and by the bridge refusing to proxy.

That is worth more to you than it sounds. It is the single clearest signal to a reviewer and to a user that your extension cannot exfiltrate their data, and it is not a promise you’re making — it’s a fact about how you’re running.

A non-empty net requires verified status, is shown prominently at install, and will slow your review down. Only ask if you genuinely cannot work without it.

If an update widens what you can do — adds a network origin, upgrades doc from read to readwrite, adds agent tools — every existing user must approve again before it applies. Until they do, your extension keeps running under the old permissions.

Narrowing is silent. Raising doc.maxBytes is silent, because a quota isn’t authority.

Design your capabilities carefully in v1. Adding one later is a re-consent prompt that some users will decline.

One channel, JSON-safe messages, so the same code runs on web and native. Binary payloads (CRDT updates, cursors, exports) travel as base64 strings; the SDK ships toBase64 / fromBase64 so you never hand-roll that.

You do not speak the wire format directly. connect() returns a typed handle:

import { connect, toBase64, fromBase64 } from '@consortium/plugin-sdk';
const host = await connect(); // resolves once the host grants capabilities
host.caps // what you were ACTUALLY granted — see below
host.docUpdate(update) // send a CRDT update (base64)
host.docSnapshot() // ask for the current document state
host.presenceSet(cursor) // publish your live cursor (base64)
host.exportFile(format, bytes, filename)
host.onTool(name, handler) // serve an agent tool you declared
host.on('doc.state', ({ update }) => { /* initial state */ });
host.on('doc.remote', ({ update }) => { /* someone else edited */ });
host.on('presence.list', ({ peers }) => { /* live cursors */ });
host.on('integrity', ({ message }) => { /* host is telling you something is wrong */ });

The wire messages underneath, for the curious (all update/cursor/bytes fields are base64):

// your extension → host
{ id, t: 'doc.update', update }
{ id, t: 'doc.snapshot' }
{ id, t: 'presence.set', cursor, label? }
{ id, t: 'export', format, bytes, filename }
{ id, t: 'net.fetch', origin, path, init? }
{ id, t: 'org.read', field: 'members' | 'name' }
{ id, t: 'tool.result', callId, result }
// host → your extension
{ t: 'ready', hostApi: 1, caps }
{ t: 'doc.state', update } // initial state
{ t: 'doc.remote', update } // someone else edited
{ t: 'presence.list', peers: [...] }
{ t: 'tool.call', callId, name, input } // an agent called your tool
{ t: 'integrity', state, message }
{ t: 'error', id?, code, message }

host.caps may be narrower than your manifest asked for — a user can decline a capability and an org policy can clamp further. Branch on what you were granted, never on what you requested; the reference whiteboard hides its peers indicator when caps.presence is false and goes read-only when caps.doc !== 'readwrite'.

Limits you should design within:

GuardDefault
Messages120/s burst, 30/s sustained
doc.update256 KB per message
Document totalyour doc.maxBytes
presence.set4 KB, 10/s
Time to ready10 s, then unmounted

Your extension’s state is a CRDT document. You get real-time multi-user editing, live cursors, and correct behaviour on reconnect — without implementing any of it.

Use Yjs. The bridge carries opaque update bytes in both directions, so any Yjs data structure works.

import * as Y from 'yjs';
import { connect, toBase64, fromBase64 } from '@consortium/plugin-sdk';
const host = await connect();
const ydoc = new Y.Doc();
const shapes = ydoc.getArray('shapes');
// 'remote' as the transaction origin is load-bearing: it is how the update
// listener below tells "the host gave me this" from "the user did this".
// Without it every inbound update is echoed straight back — a sync loop.
const applyRemote = ({ update }) => Y.applyUpdate(ydoc, fromBase64(update), 'remote');
host.on('doc.state', applyRemote);
host.on('doc.remote', applyRemote);
ydoc.on('update', (update, origin) => {
if (origin === 'remote') return;
host.docUpdate(toBase64(update));
});
shapes.observeDeep(render);

That is the whole integration. Encryption, transport, membership and eviction are the host’s problem.

Every change you emit becomes a permanent, signed operation in an append-only log. The log is not currently pruned, so operation count is the cost that lasts, not bytes on screen.

Emit one operation per meaningful user action, not per input event:

// Wrong — an operation per pointer sample, hundreds per stroke.
canvas.onpointermove = (e) => shapes.push([point(e)]);
// Right — accumulate locally, commit once when the stroke ends.
let current = [];
canvas.onpointermove = (e) => current.push(point(e));
canvas.onpointerup = () => { shapes.push([{ type: 'stroke', points: current }]); current = []; };

Yjs batches changes made inside one transaction, so Y.transact() is the tool for grouping a multi-part edit into a single operation.

Live cursors are exempt — presence.set rides an ephemeral channel that expires and never enters the document log, so you can send it as often as you like.

Presence is free:

canvas.onpointermove = (e) => host.presenceSet(toBase64(encodeXY(e.x, e.y)));
host.on('presence.list', ({ peers }) => renderCursors(peers));

Each peer arrives as { ref, label, cursor }. ref is an opaque per-session handle, deliberately not an account id — your sandbox never learns who anyone is. label is a display name the HOST chose for that person; your extension cannot set its own, which is what stops one collaborator impersonating another. Beacons are relayed live and never stored; a peer who misses one misses nothing, and a cursor that stops arriving should expire from your canvas (the reference whiteboard uses a 30-second TTL).

Tools you declare in agentTools are registered into the session’s toolset, namespaced so they can’t collide:

host.onTool('add_note', ({ text, x, y }) => {
shapes.push([{ type: 'note', text, x, y }]);
return { ok: true };
});

agentTools requires verified status. It is the highest-trust capability in the platform, for a reason explained below.

These are limits of the platform, not bugs. Knowing them will save you shipping something unsafe.

If you declare agentTools, text inside your document can be read by an AI agent that also has access to the user’s files, connectors, and workspace.

That means document content is untrusted input — including to you. If your extension is multi-user, one person can write content that another person’s agent reads. Treat anything a user typed as data, never as instructions, and never build a feature whose safety depends on document text being well-behaved.

Consortium applies its own defences here — content originating from extensions is marked untrusted, and agents that have read it are restricted from acting outward until their user speaks again. Design as though those defences are the last line, not the first.

What you render is not necessarily what is stored

Section titled “What you render is not necessarily what is stored”

The host holds the authoritative copy of your document but does not understand your schema. Nothing forces the pixels you draw to match the bytes you store.

For a whiteboard this doesn’t matter. For anything that renders a decision — an amount, a recipient, an approval — it matters a great deal, and you should not build it as a view extension. Security-relevant confirmation has to be rendered by the host, outside your sandbox.

You get one CRDT document per install scope, bounded by doc.maxBytes. There is no query engine and no server-side storage of your own. If you need one, that is a remote extension talking to your own backend — with all the trust implications that carries for your users.

An agent running on Consortium’s servers can only access an encrypted document if the user has explicitly granted server-readable custody for it. Your extension should work when that grant is absent, because most of the time it will be.

Today — development and self-hosted servers. The full runtime path ships now: a real bundle, fetched over the network, digest-verified on the device, executed in an opaque-origin sandbox with only its granted capabilities. What does not exist yet is third-party publishing INTO Consortium’s hosted marketplace — there is no publisher identity or signature chain, so listing on a server requires operator access to that server. On your own dev or self-hosted deployment that is you, and the loop is short:

yarn build # bundle your extension (see the guide)
yarn tsx sources/recipes/publishViewBundle.ts \
--dir path/to/your-extension --slug your-app --entry main.js
# then install it from the in-app marketplace and open it in a workspace

See Build a realtime whiteboard for the complete walk from empty directory to two live cursors.

Where this is going — hosted third-party publishing:

verify domain → build → hash → sign → review → list

Provenance tiers

TierRequiresGets
officialBuilt by ConsortiumEverything
verifiedDomain verified, code reviewed, signednet, agentTools, org placement
communitySigned, unrevieweddoc, presence, export. No net. No agentTools.
privateYour org onlyWhatever your org grants

Installs pin an exact version and content hash. Every mount re-verifies the hash and your signature before running anything. A revoked version stops loading, including in sessions already open.

  • net is empty, or you can explain in one sentence why it can’t be
  • Every capability you declare is used; anything unused is removed
  • Your extension behaves correctly when another user writes hostile content into a shared document
  • Nothing security-relevant is rendered inside your sandbox
  • You handle doc.remote arriving at any time, including mid-edit
  • You handle being unmounted without warning
  • Your capabilities are the set you can live with, because widening them prompts every user again
  • Security — how Consortium protects data generally
  • Agents — what agents can do, and how permissions bound them
  • Architecture — where extensions sit in the system