extension_guide.md is the reference — it tells you what
every field and hook does. This is the other thing: one extension, built from an
empty directory to a working, installable tool, in the order you would actually
build it.
We are going to write Bookmarks: save named locations in a project
(retry loop → worker/turn.go:212) and recall them later. It is small enough to
finish and real enough to keep.
By the end it will have four capabilities — a pair of tools, an approval-gated
tool, a slash command, and a sidebar card — plus a user setting. You can read the
finished code at any point in
examples/extensions/bookmarks/.
You do not need Juggler's source for this. Everything ships with the app.
juggler ext init bookmarks
cd bookmarksYou get a complete extension that already loads:
bookmarks/
juggler.extension.json
context-items/echo-context-item.js
strategies/sample-strategy-type.js
commands/hello-command-type.js
README.md
Install it into your own Juggler and see it work before changing anything:
juggler ext link .That symlinks the directory into ~/.juggler/extensions/ (validating it first).
Start or reconnect to Juggler, and from now on saving any file hot-reloads the
extension in connected viewers. No restart, all the way through this tutorial.
Check it arrived: Settings → Extensions should list bookmarks with its three capabilities. Ask the model to echo something and it will call the sample tool.
We do not need a strategy, so delete it:
rm strategies/sample-strategy-type.jsThen remove the "strategies" line from provides in
juggler.extension.json. A glob that matches nothing is not refused at load
— the extension simply contributes nothing for that type, silently. Only
juggler ext validate reports it:
juggler ext validate .Which is the argument for running it: a typo'd glob is otherwise indistinguishable from a capability that mysteriously never appears.
Bookmarks need to live somewhere. .juggler/bookmarks.json in the project is a
good home: ops resolve relative paths against the project root, so bookmarks
follow the project rather than the machine.
Create lib/bookmark-store.js. Note the directory — files under lib/ carry no
capability suffix, so none of the manifest's globs match them. This is a plain
module the capabilities import, not a capability itself.
import { readFile, writeFile } from 'juggler/ops';
const STORE_PATH = '.juggler/bookmarks.json';
export async function loadBookmarks(signal) {
try {
const file = await readFile({ path: STORE_PATH }, signal);
const parsed = JSON.parse(file.content);
return Array.isArray(parsed) ? parsed : [];
} catch {
return []; // no file yet is the normal first-run state, not an error
}
}
export async function saveBookmarks(bookmarks, signal) {
await writeFile(
{ path: STORE_PATH, content: `${JSON.stringify(bookmarks, null, 2)}\n` },
signal
);
}
// One line per bookmark. Used by the tool, the command and the card alike —
// which is most of the reason this module exists.
export function format(bookmarks) {
return bookmarks
.map(b => `${b.name} — ${b.path}${b.line ? `:${b.line}` : ''}`)
.join('\n');
}juggler/ops is the privileged host layer — filesystem, shell, search, web.
Extensions import only from juggler/* specifiers; that is the same surface
the built-in extension uses, which is what guarantees a third-party extension can
do anything a built-in can.
Replace context-items/echo-context-item.js with
context-items/bookmarks-context-item.js. A context item is a tool the model can
call, and there are four members that matter:
import ContextItem from 'juggler/context-item';
import { loadBookmarks, saveBookmarks, format } from '../lib/bookmark-store.js';
class BookmarksContextItem extends ContextItem {
static MANIFEST = {
id: 'bookmarks',
name: 'Bookmarks',
version: '1.0.0',
description: 'Save and recall named locations in the project',
requiresApproval: false
};
static getToolDefinitions() {
return [
{
name: 'bookmark_add',
category: 'write',
description: 'Save a named bookmark pointing at a file, optionally a specific line.',
input_schema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Short label for this location' },
path: { type: 'string', description: 'Project-relative file path' },
line: { type: 'number', description: 'Optional 1-indexed line number' }
},
required: ['name', 'path']
}
},
{
name: 'bookmark_list',
category: 'read',
description: 'List every saved bookmark in this project.',
input_schema: { type: 'object', properties: {} }
}
];
}
async execute(params) {
const bookmarks = await loadBookmarks(this.signal);
if (params.name === undefined) {
return { action: 'list', count: bookmarks.length, listing: format(bookmarks) };
}
const kept = bookmarks.filter(b => b.name !== params.name);
kept.push({ name: params.name, path: params.path, line: params.line,
savedAt: new Date().toISOString() });
await saveBookmarks(kept, this.signal);
return { action: 'add', name: params.name, count: kept.length };
}
getSummary(outcome) {
if (!outcome.success) return this.failureSummary(outcome.error);
const result = outcome.result; // ← see the trap below
if (result.action === 'list') {
return result.count === 0
? this.successSummary('No bookmarks saved.')
: this.successSummary(this.truncateForLLM(result.listing));
}
return this.successSummary(`Saved bookmark "${result.name}".`);
}
}
export default BookmarksContextItem;Save the file and ask the model to bookmark something. It works already.
Four things in there are worth stopping on.
category is not decoration. It is what strategies gate on: a read-only
strategy keeps read and meta and drops write. Label bookmark_add as
write and it correctly disappears under read-only, instead of quietly writing
to disk in a mode the user chose for safety.
Two tools, not one tool with a mode. A model chooses between two well-named
tools far more reliably than between two modes of one. Do not collapse them to
bookmark({action}).
summary IS what the model reads. There is no separate LLM-content hook:
getSummary()'s summary field is both the tool_result content and the
transcript line. So the actual listing goes there, wrapped in
this.truncateForLLM() so a huge result is capped at the conversation's budget
rather than blowing it.
The trap: outcome.result, not outcome. execute() returns raw data and
the framework wraps it:
{ success: true, result: <what execute returned>, prepared: …, error: … }Reading outcome.action gives undefined, and the model sees an empty result
with nothing explaining it. This is the single most common mistake in a first
extension, and it fails quietly.
Right now bookmark_add with a blank name writes a nameless bookmark. Add
validate() — it runs before anything executes, and its error text goes to the
model:
async validate(toolInput) {
if (toolInput.name === undefined && toolInput.path === undefined) {
return { valid: true, params: toolInput }; // bookmark_list takes nothing
}
if (typeof toolInput.name !== 'string' || toolInput.name.trim() === '') {
return { valid: false, error: 'Parameter "name" must be a non-empty string' };
}
if (typeof toolInput.path !== 'string' || toolInput.path.trim() === '') {
return { valid: false, error: 'Parameter "path" must be a project-relative file path' };
}
return { valid: true, params: { ...toolInput, name: toolInput.name.trim() } };
}Write these messages as instructions, not diagnostics. The model is the reader, and "must be a non-empty string" tells it what to do next; "invalid input" does not.
Clearing every bookmark should stop and ask. Approval is declared per class,
so this goes in a new file, context-items/bookmark-clear-context-item.js —
putting it on the existing class would gate listing too.
import ContextItem from 'juggler/context-item';
import { loadBookmarks, saveBookmarks, format } from '../lib/bookmark-store.js';
class BookmarkClearContextItem extends ContextItem {
static MANIFEST = {
id: 'bookmark-clear',
name: 'Clear Bookmarks',
version: '1.0.0',
description: 'Delete every saved bookmark in this project',
requiresApproval: true // park the call and ask
};
static getToolDefinitions() {
return [{
name: 'bookmark_clear',
category: 'write',
description: 'Delete every saved bookmark in this project. Cannot be undone.',
input_schema: { type: 'object', properties: {} }
}];
}
async getApprovalConfig(_params) {
const bookmarks = await loadBookmarks(this.signal);
return {
title: 'Clear bookmarks',
message: `Delete all ${bookmarks.length} bookmarks? This cannot be undone.\n\n`
+ format(bookmarks)
};
}
async execute(_params) {
const bookmarks = await loadBookmarks(this.signal);
await saveBookmarks([], this.signal);
return { removed: bookmarks.length };
}
}getApprovalConfig() runs before approval, so it can read the store and name
what is about to be lost. That is the difference between a dialog someone reads
and a dialog someone clicks through: "Delete all 14 bookmarks?" with the list
underneath, not "Run bookmark_clear?".
Note the manifest carries two ids and they are different things: the
extension id in juggler.extension.json is the unit the user enables or
disables; each capability's MANIFEST.id identifies that capability. Two
extensions cannot claim the same capability id — a duplicate is a surfaced load
error, not a silent overwrite.
An unbounded bookmark list is a slow leak. Add a cap the user controls, in
juggler.extension.json:
Save, and the control appears under Settings → Extensions → Bookmarks. You write no UI.
Read it back with extensionConfigResolve, keyed by your extension id:
import { extensionConfigResolve } from 'juggler/ops';
const EXTENSION_ID = '@local/bookmarks'; // must match the manifest exactly
export async function maxBookmarks(signal) {
try {
const config = await extensionConfigResolve({ extId: EXTENSION_ID }, signal);
const value = Number(config.max_bookmarks);
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 50;
} catch {
return 50;
}
}Then enforce it in execute(), adding maxBookmarks to that file's import from
../lib/bookmark-store.js:
const cap = await maxBookmarks(this.signal);
const final = kept.length > cap ? kept.slice(kept.length - cap) : kept;
await saveBookmarks(final, this.signal);Defaults are applied on read, so a user who never touches the setting still gets 50 — the fallback above is for the case where the op itself fails.
If you need an API key, declare it as "type": "secret". Secrets are stored
in the credential store rather than the settings file, and only
extensionConfigResolve returns their values — extensionConfigGet, which the
settings UI uses, sees a presence marker and never the secret itself. A secret
may not declare a default.
Asking a model to list your bookmarks costs a turn. A slash command costs
nothing. Replace commands/hello-command-type.js with
commands/bookmarks-command-type.js:
import CommandType from 'juggler/command-type';
import { loadBookmarks, format } from '../lib/bookmark-store.js';
class BookmarksCommandType extends CommandType {
static MANIFEST = {
id: 'bookmarks',
name: 'Bookmarks',
version: '1.0.0',
description: 'List the bookmarks saved in this project'
};
async execute(args) {
const bookmarks = await loadBookmarks();
if (bookmarks.length === 0) return { handled: true, message: 'Nothing bookmarked yet.' };
return { handled: true, message: format(bookmarks) };
}
}Type /bookmarks. That is the whole capability — no LLM, no approval, no schema.
Commands cannot perform host side-effects directly. To open a thread or set the
composer draft, declare it on sideEffects and the host dispatches it. And
if your command writes to the conversation, set both mutatesConversation: true
(so the host settles any live turn before execute() runs) and
coalesceUndo: true (so a multi-step change reverts as one undo). This one only
reads, so it needs neither.
Note: if all you want is a reusable prompt, you do not need an extension at all — a custom slash command is a markdown file with no code. Reach for this capability when the command needs to do something.
Last capability: a sidebar card, in cards/bookmarks-card.js. Add the glob to
provides as "infoCards": ["cards/*-card.js"].
import InfoCardType from 'juggler/info-card-type';
import { loadBookmarks } from '../lib/bookmark-store.js';
let cached = []; // outside the class — see below
class BookmarksCard extends InfoCardType {
static MANIFEST = {
id: 'bookmarks',
name: 'Bookmarks',
version: '1.0.0',
description: 'List this project’s saved bookmarks in the sidebar.',
eyebrow: 'Bookmarks',
priority: 5
};
hasContent() {
return cached.length > 0; // no bookmarks → no card in the rail
}
mount(contentEl) {
let disposed = false;
const paint = () => { /* …build elements from `cached`… */ };
const refresh = async () => {
const bookmarks = await loadBookmarks();
if (disposed) return;
cached = bookmarks;
paint();
};
paint();
void refresh();
const timer = setInterval(() => void refresh(), 15000);
return () => { disposed = true; clearInterval(timer); }; // teardown
}
}Two things here are easy to get wrong.
Return the teardown. Without it the interval outlives the element, and the rail removes and rebuilds cards more often than you would think.
Cache outside the class. When the sidebar runs short of room it drops the
lowest-priority card — a real teardown — and rebuilds it when the room comes
back. So mount() runs on every resize, not once per session. A card that
fetched on each mount would fire a request per frame during a window drag. Paint
from cache, refresh quietly.
Cards are viewer-only: they touch the DOM and never run in the engine, which is why this is the one capability type with no worker twin to think about.
juggler ext validate .This runs the server's admission check — required manifest fields and
engineApi compatibility with this host — plus the glob check from step 1 that
discovery does not do. A packaging mistake fails here with a clear ✗ rather
than becoming an extension that mysteriously never appears.
Add tests while you are at it — declare them in the manifest:
"provides": { "tests": ["_tests/*-test.js"] }The leading underscore matters: it is what keeps test files out of an embedded
build. tests is test-only and does not count as a capability.
To publish, put the directory in a git repository. Anyone can then install it:
juggler ext add github.com/you/bookmarksThat clones into ~/.juggler/extensions/, printing your declared permissions
and asking for confirmation first. There is no auto-update — users update
with git pull in that directory, then reconnect.
Before you ship, be honest in permissions. It is disclosure, not a sandbox:
extensions run unsandboxed with the full privileges of the app, and the list is
what lets someone decide whether to trust yours. Ours is:
"permissions": ["filesystem.read", "filesystem.write"]extension_guide.md— the reference. Every manifest field, all five capability types, the trust model,contextPosition, sub-agents.web/sdk/context-item.js,strategy-type.js,command-type.js,info-card-type.js,file-viewer.js— the base classes. Each opens with a quickstart and a full method reference, and is the canonical API documentation. Inside the app, theReadJugglerSourcetool reads them with no checkout.../examples/extensions/— the finished Bookmarks extension, plus small standalone examples of the capability types it doesn't cover.web/extensions/juggler-core/— the built-in extension, loaded through exactly the same path as yours. Much bigger, and the best reference for real detail.
The capability we never touched here is the strategy — a policy for how the
whole agentic loop runs, which tools exist, and what gets auto-approved. See
examples/extensions/focus-strategy/ for a small one.