---
name: jootle-toolkit-builder
description: >
  Build, validate, and deliver Jootle Toolkit Format (JTF v1.1) bundles for the
  Jootle platform. Use when the user wants to create a Jootle toolkit, build a
  `.jtf.json` file, package a custom workflow as a Jootle extension, author
  custom entity types and playbooks for a Jootle instance, ship custom AI
  agents, autonomous goals, seeded knowledge graph content, pre-populated
  entity instances, or bundled methodology documents, or design tool HTML
  pages that use the `window.jootle.*` bridge. Triggers on mentions of Jootle
  toolkit, JTF, `.jtf.json`, Jootle Toolkit Format, custom Jootle entity type,
  Jootle playbook authoring, `window.jootle` bridge, Jootle tool page,
  Jootle module packaging, knowledge_seed, toolkit documents, or custom
  specialized agents.
---

# Jootle Toolkit Builder (JTF v1.1)

This skill makes Claude able to author **Jootle Toolkit Format (JTF) v1.1** bundles — single-file `.jtf.json` archives that install into any Jootle instance and deliver a complete, self-contained capability (UI pages, custom entity types, playbooks, menu entries, custom agents, autonomous goals, seeded knowledge graph content, pre-populated entity instances, and bundled reference documents).

Use it when the user asks you to build a Jootle toolkit. Output a valid `.jtf.json` they can import via **Tools → Import toolkit…** in their instance.

This skill is the **authoritative reference for JTF v1.1**. It supersedes any older `docs.jootle.com` content for build-time decisions; the docs site is intentionally lighter than this skill.

**v1.1 is purely additive over v1.0.** Every v1.0 bundle still installs unchanged on a v1.1 instance. v1.1 introduces five new top-level fields — `agents[]`, `goals[]`, `entity_instances[]`, `knowledge_seed`, and `documents[]` — plus a `budget_profile` hint on agents. Set `bundle_schema: "1.1"` if you use any of them; v1.0 instances reject 1.1 bundles with a clear error.

---

## What a Jootle toolkit is — the mental model

A Jootle toolkit is a packaged extension of a Jootle instance. It contains:

- **One or more HTML pages** ("members") — sandboxed UI surfaces mounted under `/app/tools/{slug}`.
- **Optional custom entity types** — new kinds of things the instance can store and reason about (a "Quote", a "Recipe", a "Governance Session").
- **Optional playbooks** — multi-step workflows the AI can run autonomously or on demand.
- **A manifest** — menu contributions (sidebar entries, palette commands, widgets) and an `ai_context` string that teaches the instance's AI what this toolkit does.

A toolkit is **NOT** a standalone app. It's an extension that participates in the instance's existing systems: the AI loop, the knowledge graph, the playbook engine, the multi-channel input loop, the theming system. Tool pages live in iframes with a CSP that blocks all outbound network calls — they reach the backend only through the `window.jootle.*` bridge.

Fresh Jootle instances ship with **zero toolkits**. Toolkits arrive only by:
1. Installing from the library (reviewed, official path).
2. Importing a `.jtf.json` file (unreviewed, "your responsibility" path).

The instance never auto-seeds toolkits at provision time.

---

## JTF v1.0 bundle shape

A complete bundle is a single JSON file with this top-level structure:

```json
{
  "bundle_schema": "1.1",
  "toolkit":    { /* identity + display */ },
  "manifest":   { /* AI context, menu, widgets */ },
  "members":    [ /* one or more HTML pages */ ],
  "entity_types":     [ /* optional custom entity types */ ],
  "playbooks":        [ /* optional playbook definitions */ ],
  "agents":           [ /* v1.1 — optional custom specialized agents */ ],
  "goals":            [ /* v1.1 — optional autonomous goals */ ],
  "knowledge_seed":   { /* v1.1 — optional seeded KG entities + relationships */ },
  "entity_instances": [ /* v1.1 — optional pre-populated entity rows */ ],
  "documents":        [ /* v1.1 — optional bundled reference documents */ ],
  "metadata":         { /* author + bill-of-materials */ }
}
```

Field-by-field reference follows.

### `bundle_schema` (string, required)

Set to `"1.0"` for v1.0 bundles or `"1.1"` if you use any of the five v1.1 fields (`agents[]`, `goals[]`, `entity_instances[]`, `knowledge_seed`, `documents[]`). v1.1 instances accept both `"1.0"` and `"1.1"` bundles; v1.0 instances reject `"1.1"` bundles with a clear error. Setting `"1.0"` while using a v1.1 field is a validator error. v2.0 will be breaking.

### `toolkit` (object, required)

```json
{
  "slug": "ideas",
  "title": "Ideas",
  "description": "Capture ideas, think them through, promote the good ones.",
  "icon": "💡",
  "category": "general"
}
```

- `slug` — unique per instance. Drives the URL (`/app/tools/{slug}`) and conflict detection on install. Use kebab-case, no spaces.
- `title` — display name. Title Case.
- `description` — one-line summary. Shown in the storefront and the import preview dialog.
- `icon` — single emoji or short string. Used in sidebar, dashboard widgets, knowledge-graph nodes.
- `category` — freeform string for library grouping. Common values: `general`, `productivity`, `sales`, `games`, `content`, `finance`, `governance`, `health`, `home`.

### `manifest` (object, required)

```json
{
  "version": "1.0.0",
  "ai_context": "You have the Ideas toolkit installed...",
  "menu": [
    {
      "id": "tk.ideas.inbox",
      "surface": "sidebar",
      "grp": "tools",
      "label": "Ideas",
      "icon": "💡",
      "href": "/app/tools/ideas",
      "sort_order": 25,
      "permission": "dashboard"
    }
  ],
  "widgets": []
}
```

**`ai_context`** — prose injected into the instance's AI system prompt when this toolkit is installed. This is how the AI learns:
- What entity types your toolkit defines.
- What tag syntax (if any) it expects (e.g., `[IDEA: create, title: "..."]`).
- When to use it vs. core primitives.
- Any naming conventions or behavioral rules.

Keep it operational and short. **Under 2000 words.** Every word pollutes every conversation on that instance.

**`menu[]`** — sidebar / palette / context-menu / dashboard contributions. Each entry has:
- `id` — globally unique. Convention: `tk.{toolkit-slug}.{descriptor}`.
- `surface` — one of `sidebar`, `palette`, `context-menu`, `dashboard`.
- `grp` — group within the surface. Common sidebar groups: `tools`, `work`, `connections`, `admin`.
- `label` — display text.
- `icon` — single emoji or short string.
- `href` — destination path. Must start with `/app/`.
- `sort_order` — integer. Lower = higher in list.
- `permission` — gate. Common values: `dashboard` (any logged-in user), `admin`.

**`widgets[]`** — dashboard widget declarations. Each:
```json
{
  "slug": "ideas-inbox-count",
  "name": "Ideas Inbox",
  "icon": "💡",
  "category": "Productivity",
  "default_col_span": 4
}
```

For v1.0, widget HTML lives inline in the manifest — keep widgets simple. Complex UI belongs in a full member tool.

### `members` (array of objects, required)

At least one member required. The first member is typically the toolkit hub landing page; additional members are linked sub-tools.

```json
{
  "slug": "ideas-inbox",
  "title": "Ideas Inbox",
  "description": "Capture, think, and promote ideas.",
  "icon": "💡",
  "content": "<!DOCTYPE html>...self-contained HTML...",
  "sort_order": 0
}
```

- `slug` — unique within the toolkit. Drives the URL: `/app/tools/{toolkit-slug}/{member-slug}` (the first member typically uses the toolkit slug as its href).
- `content` — **entire HTML document** with inline `<style>` and `<script>`. Rendered in a sandboxed iframe.
- **Size limit: 512 KB of HTML per member** (enforced at library submit).

See the [Member HTML guide](#member-html-pages) below for what the iframe environment provides.

### `entity_types` (array of objects, optional)

```json
{
  "slug": "idea",
  "name": "Idea",
  "plural_name": "Ideas",
  "icon": "💡",
  "description": "A lightweight idea entity that grows structure as you commit to it",
  "schema_hint": {
    "fields": [
      { "key": "title",   "type": "text",   "label": "Title", "required": true },
      { "key": "summary", "type": "text",   "label": "Summary" },
      { "key": "status",  "type": "enum",   "label": "Status",
        "options": ["raw", "exploring", "shaping", "committed", "shelved", "killed"] },
      { "key": "score",   "type": "number", "label": "Score" },
      { "key": "starred", "type": "boolean", "label": "Starred" }
    ],
    "display_name_field": "title",
    "subtitle_field": "summary"
  }
}
```

- `slug` — **globally unique across all toolkits on the instance**. Install will detect conflicts. Pick a name that's clearly scoped to your toolkit's domain.
- Valid field `type` values: `text`, `enum` (with `options`), `number`, `boolean`, `json`.
- `display_name_field` — which field renders as the entity's name in lists and KG nodes.
- `subtitle_field` — secondary display field.

Note: every entity *also* has a built-in `name` field (set by the API on create). The `display_name_field` lets you override which schema field acts as the user-visible name.

### `playbooks` (array of objects, optional)

```json
{
  "playbook": {
    "id": "pb_notes_summarize",
    "name": "Summarize note",
    "description": "Produce a one-line summary of a note",
    "status": "active",
    "source": "seed",
    "category": "standalone",
    "trigger_config": { "triggers": [] },
    "variables_schema": {
      "note_id": { "type": "string", "required": true },
      "body":    { "type": "string", "required": true }
    }
  },
  "nodes": [
    {
      "id": "pb_notes_summarize_step",
      "node_type": "step",
      "label": "Summarize",
      "config": {
        "agent": "documentation",
        "instruction": "Summarize the following note in one sentence.\n\n{{body}}\n\nOUTPUT:artifact_markdown=<one-sentence summary>",
        "outputs": ["artifact_markdown"]
      },
      "ordering": 1
    },
    {
      "id": "pb_notes_summarize_end",
      "node_type": "end",
      "label": "Done",
      "ordering": 2
    }
  ],
  "edges": [
    {
      "from_node_id": "pb_notes_summarize_step",
      "to_node_id": "pb_notes_summarize_end",
      "priority": 0
    }
  ]
}
```

**Node types:**
- `step` — execute an agent with an `instruction`. Most common.
- `gate` — pause for human approval before continuing.
- `end` — terminal node. Every playbook needs at least one.
- `condition` — branch on a variable value.
- `loop` — iterate over a collection.
- `sub_playbook` — invoke another playbook as a sub-step.

**Variables:** declared in `variables_schema`, passed at invocation time, interpolated with `{{var_name}}` in `config.instruction`. Outputs from a step are extracted from agent responses via `OUTPUT:key=value` lines (one per line) and become variables available to downstream nodes.

**Agents:** the `config.agent` value names a specialized agent. Standard roster includes: `software_development`, `research_feasibility`, `customer_support`, `documentation`, `data_analytics`, `marketing`, `legal_evaluation`, `qa_testing`, `media_creation`, `web_developer`, `delivery`, `document_retrieval`, `business_strategy`, `system_administration`, `security_hardening`, `privacy_verification`, `connector_builder`, and others. If your toolkit needs a non-standard agent, flag it in `ai_context` and document it for the customer to add.

### `agents` (array of objects, optional — v1.1)

Ships custom specialized agents (AI personas with their own system prompts). Standard agent roster — `documentation`, `research_feasibility`, `customer_support`, etc. — is always available; use `agents[]` only when your toolkit needs a non-standard persona.

```json
"agents": [
  {
    "name": "idea_isis",
    "description": "You are Isis, the diagnostic sensing instrument of the IDEA Governance framework...\n\n## Protocol\n\n...",
    "budget_profile": "deep"
  },
  {
    "name": "idea_eddie",
    "description": "You are Eddie, the validation instrument...",
    "budget_profile": "deep"
  }
]
```

- `name` — globally unique on the instance. **Prefix with your toolkit slug** to avoid collisions (`idea_isis`, not bare `isis`). The install path refuses to clobber an agent owned by a different toolkit.
- `description` — the full system prompt for the agent. Long is fine; this isn't injected per-conversation — it's only loaded when the agent is invoked.
- `budget_profile` (optional) — `"fast" | "balanced" | "deep" | "thinking"`. Soft hint to dispatch about which model class the agent is designed for. Customer Routing Rules still win; this is just a default for instances that haven't configured rules. Omit if you don't have an opinion (instance default applies).
- `display_name` (optional) — human-readable label. Defaults to title-cased `name`.

Bundle install ordering: agents install **before** playbooks and goals, so references like `agent: "idea_isis"` in playbook nodes and goal rules resolve cleanly.

**Uninstall semantics:** agents are flagged `paused_until_reinstall=true` (preserved, not deleted). Reinstall reactivates them. Goals + playbooks belonging to the same toolkit are paused naturally when their author-agents are.

### `goals` (array of objects, optional — v1.1)

Ships autonomous goals — patrol loops, scheduled scans, drift detectors, daily-summary jobs.

```json
"goals": [
  {
    "title": "Staging buffer monitor",
    "description": "Watch staging_buffer_item entities for entries sitting in awaiting_pb04 routing for >24h; surface to the meta-governance session as a stalled-routing finding.",
    "rules": {
      "trigger": "schedule",
      "agent": "idea_isis",
      "context_query": "type=staging_buffer_item AND routing_status=awaiting_pb04 AND deposited_at < now() - 24h"
    },
    "schedules": { "cron": "0 */6 * * *", "timezone": "UTC" },
    "reporting_cadence": "weekly",
    "status": "active"
  }
]
```

- `title` — unique per toolkit. Install upsert key is `(toolkit_id, title)`.
- `rules` — JSON blob the goal runner consumes. Typically includes `trigger`, `agent`, `context_query`, plus goal-specific fields.
- `schedules` — JSON blob, typically `{ "cron": "...", "timezone": "..." }`.
- `reporting_cadence` — how often the goal reports up (`"daily"`, `"weekly"`, etc.).
- `status` — `"active"` (default) or `"paused"`.

**Uninstall semantics:** goals are set to `status='paused'` (rows preserved). Reinstall reactivates. Customer-modified goals (when a future UI lets them edit) skip update on re-import; you'll see them in the import preview.

### `entity_instances` (array of objects, optional — v1.1)

Pre-populated entity rows for toolkits that ship seed data — pre-defined governance session types, default pipeline stages, a starter catalogue.

```json
"entity_instances": [
  {
    "entity_type": "governance_session_template",
    "name": "Standard GS Full Processing",
    "data": {
      "pipeline_contexts": 19,
      "default_pathway": "P1",
      "gate_sequence": ["A", "B", "C", "D"],
      "knowledge_links": [
        "ITW 2.48 — Advancement as Governing Attractor",
        "GP-011 — Governance Illumination Boundary"
      ]
    },
    "force_update_on_reimport": false
  }
]
```

- `entity_type` MUST refer to either an entity type declared in this same bundle's `entity_types[]`, or a core entity type (`contact`, `project`, `task`, etc.). Cross-toolkit references are not supported in v1.1.
- `name` — uniqueness key within `(entity_type, name, seeded_by_toolkit_id)`. If a customer has already created an entity with this name, the seed is **skipped** — preserve customer intent.
- `data` — JSON blob matching the entity type's schema.
- `data.knowledge_links` (optional) — array of knowledge entity names declared in this bundle's `knowledge_seed.entities[]`. Resolved at runtime via the existing `window.jootle.knowledge.get(name)` bridge. Survives re-imports because lookups are by name, not ID.
- `force_update_on_reimport` (optional, default `false`) — if `true`, re-imports update `data` for the existing row. Use only for canonical reference data the toolkit author wants to keep authoritative.

**Uninstall semantics:** seed instances are **preserved**. The `seeded_by_toolkit_id` column tags them for the explicit "delete this toolkit's data" action.

### `knowledge_seed` (object, optional — v1.1)

Bulk knowledge graph content — entities and relationships the AI can search and cite. The biggest single v1.1 feature for methodology-heavy toolkits.

```json
"knowledge_seed": {
  "group_id": "idea-register",
  "entities": [
    {
      "name": "ITW 2.48 — Advancement as Governing Attractor",
      "entity_type": "concept",
      "summary": "Governance-advancement inseparability — advancement is the governing attractor of any enterprise system. ...",
      "attributes": {
        "tier": "core",
        "thoughtware_class": "ITW"
      }
    },
    {
      "name": "GP-011 — Governance Illumination Boundary",
      "entity_type": "concept",
      "summary": "...",
      "attributes": { "tier": "constitutional", "thoughtware_class": "GP" }
    }
  ],
  "relationships": [
    {
      "source": "ITW 2.48 — Advancement as Governing Attractor",
      "target": "GP-011 — Governance Illumination Boundary",
      "type": "constrained_by",
      "fact": "Advancement-as-attractor operates within illumination-boundary constraints.",
      "weight": 0.8
    }
  ]
}
```

- `group_id` — scopes seeded entities so they're identifiable and groupable. Convention: `<toolkit-slug>-<purpose>` (`idea-register`, `crm-stages`). Not enforced — the platform trusts the author.
- `entities[]` — each entity has `name`, `entity_type` (free-form string, common values: `concept`, `person`, `place`, `event`, `organization`), `summary` (short prose), `attributes` (JSON blob).
- `relationships[]` — `source` and `target` are entity NAMES (not IDs), matched against `entities[]` within this same bundle. `type` is the predicate (`related`, `caused_by`, `constrained_by`, etc.). `fact` is a one-sentence natural-language statement. `weight` (0.0–1.0) is the confidence/strength.

**Install behaviour:** bulk insert, **no LLM extraction** (seed data is pre-validated). Idempotency: skip on `(name, group_id)` conflict; customer-added relationships and customer-modified entities are preserved on re-import.

**Uninstall semantics:** preserve everything. The customer's KG has absorbed the toolkit's contribution; pulling it back out damages relationship chains. "Delete this toolkit's data" purges by `group_id` or `seeded_by_toolkit_id`.

**Use the bridge to query at runtime:**

```js
const results = await window.jootle.knowledge.search('governance attractor', 10);
const itw248 = await window.jootle.knowledge.get('ITW 2.48 — Advancement as Governing Attractor');
```

### `documents` (array of objects, optional — v1.1)

Bundled reference documents — methodology specs, sales playbooks, QMS templates. The substance the toolkit's AI needs to consult but that does **NOT** belong in `ai_context` (which gets injected on every conversation, polluting the system prompt). Retrieved on demand.

```json
"documents": [
  {
    "slug": "gs-spec",
    "name": "Governance System Specification v3.7.0",
    "content_type": "text/markdown",
    "content": "# Governance System Specification\n\n## Part I — Constitutional Layer\n\n### Triad Logic\n\n...full document body, can be tens of thousands of words..."
  }
]
```

- `slug` — unique within this toolkit (different toolkits can each ship a `gs-spec` — they're scoped per toolkit).
- `content_type` — `"text/markdown"` (default), `"text/plain"`, or `"application/json"`. Binary types deferred to a future version.
- `content` — the full document. **Per-document cap: 2 MB.** Most methodology docs are well under 500 KB.

**Section indexing.** At install time, markdown documents are parsed on `# Heading` structure (ATX h1–h3). Each section becomes an indexed row keyed by its slash-joined heading path. Example: a `## Isis Sensing Protocol` under `# Part III — The Instruments` has section path `Part III — The Instruments/Isis Sensing Protocol`. Members can pull just the section they need:

```js
// Retrieve a specific section (load 2k tokens, not 50k)
const sec = await window.jootle.documents.get('gs-spec', {
  section: 'Part III — The Instruments/Isis Sensing Protocol'
});
// sec = { slug, name, content_type, size_bytes, section: { section_path, heading_level, heading_text, content } }

// List sections (table of contents)
const sections = await window.jootle.documents.sections('gs-spec');
// sections = [{ section_path, heading_level, heading_text, section_order }, ...]

// Retrieve the whole document (no section)
const doc = await window.jootle.documents.get('gs-spec');
// doc = { slug, name, content_type, size_bytes, content }
```

**How the AI uses documents.** The toolkit's `ai_context` (under 2000 words) orients the AI: "*You have a `gs-spec` document. When a submission needs validation, retrieve specific sections via the document bridge — don't load the whole thing.*" Substance lives in retrievable storage; orientation lives in the system prompt. This is the load-bearing UX for big-document toolkits.

**Uninstall semantics:** documents are preserved by default. Customer annotations (when a future UI supports them) stay intact. "Delete this toolkit's data" hard-deletes via cascade.

### `metadata` (object)

```json
{
  "author": "Your Name or Org",
  "publisher_instance": "your-instance-slug",
  "bill_of_materials": {
    "tool_count": 1,
    "widget_count": 0,
    "entity_type_count": 1,
    "entity_types": ["idea"],
    "playbook_count": 2,
    "playbooks": ["pb_ideas_summarize", "pb_ideas_promote"],
    "menu_items": 1,
    "has_goals": false,
    "agent_count": 0,
    "goal_count": 0,
    "entity_instance_count": 0,
    "knowledge_seed_entity_count": 0,
    "knowledge_seed_relationship_count": 0,
    "document_count": 0,
    "document_total_bytes": 0
  }
}
```

Purely informational — drives admin review UI and import preview dialog. Compute counts from your actual content. The v1.1 BOM fields (`agent_count` etc.) should be zero or omitted for v1.0 bundles.

---

## Member HTML pages

Each member is a complete HTML document. The instance renders it in a `<iframe sandbox>` element with:

1. **A CSP that blocks all outbound network calls.** `connect-src 'none'; font-src 'none'`. The iframe can't fetch from external URLs.
2. **A `window.jootle.*` bridge** that proxies API calls through the parent.
3. **Theme tokens** injected as CSS variables so pages stay theme-aware.

### Boilerplate structure

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>My Tool</title>
  <style>
    :root {
      /* Theme tokens are auto-injected as --bg-default, --accent, etc.
         Just reference them; the parent owns the values. */
    }
    body {
      font-family: var(--font-sans);
      background: var(--bg-default);
      color: var(--text-primary);
      margin: 0;
      padding: var(--space-4);
    }
    .card {
      background: var(--bg-paper);
      border: 1px solid var(--border-default);
      border-radius: var(--radius-md);
      padding: var(--space-3);
    }
    .accent { color: var(--accent); }
  </style>
</head>
<body>
  <h1>My Tool</h1>
  <div id="content"></div>
  <script>
    (async () => {
      // Use the bridge — never raw fetch().
      const r = await window.jootle.entity.list('idea', { limit: 50 });
      document.getElementById('content').innerHTML = r.items
        .map(i => `<div class="card"><b>${i.name}</b>: ${i.data.summary || ''}</div>`)
        .join('');
    })();
  </script>
</body>
</html>
```

### `window.jootle.*` bridge API

All methods return Promises and proxy through `postMessage` to the parent, which makes the actual HTTP call with auth headers attached.

#### `window.jootle.fetch(path, options?)` — low-level

```js
const r = await window.jootle.fetch('/api/projects', { method: 'GET' });
// r = { status: 200, data: {...}, ok: true }
```

Path **must** start with `/api/`. The parent strips and validates the path. 30-second timeout. Use the higher-level helpers below in preference.

#### `window.jootle.entity.*` — entity CRUD

```js
// Create
const created = await window.jootle.entity.create('idea', {
  name: 'A new idea',
  data: { title: 'A new idea', summary: 'Worth exploring', status: 'raw' }
});

// Get
const item = await window.jootle.entity.get('idea', '<id>');

// Update
await window.jootle.entity.update('idea', '<id>', {
  data: { status: 'exploring' }
});

// Delete
await window.jootle.entity.delete('idea', '<id>');

// List — supports { q, status, limit, offset, toolkit_id }
const list = await window.jootle.entity.list('idea', { limit: 50 });
// list = { items: [...], total: N }

// Search (alias for list with q)
const found = await window.jootle.entity.search('idea', 'climate', 20);
```

The `name` field is at the top level; everything else lives under `data`.

#### `window.jootle.brain.*` — LLM access

```js
// Free-form prompt — counts against the tool's brain budget
const r = await window.jootle.brain.ask(
  'Summarize this in one sentence: ' + text,
  { max_tokens: 200 }
);
// r = { text: "...", model: "...", tokens_used: N }

// Classification with discrete labels
const c = await window.jootle.brain.classify(
  'Is this a question or a statement?: ' + text,
  ['question', 'statement']
);
// c = { label: "question", confidence: 0.95 }
```

Handle `429` (`code: 'budget_exceeded'`) gracefully — fall back to a non-LLM behavior. Don't spam the user with budget errors.

#### `window.jootle.playbooks.*` — invoke shipped workflows

```js
// Invoke a playbook from your toolkit
const { run_id } = await window.jootle.playbooks.invoke(
  'pb_notes_summarize',
  { note_id: id, body: body },
  { toolkit_slug: 'notes' }
);

// Poll for completion
const status = await window.jootle.playbooks.runStatus(run_id);
// status = { status: 'completed' | 'running' | 'failed', outputs: {...} }
```

#### `window.jootle.knowledge.*` — KG access

```js
// Search the KG
const results = await window.jootle.knowledge.search('climate policy', 10);

// Get an entity by name (returns null on 404)
const entity = await window.jootle.knowledge.get('Climate Policy');

// Write a fact — LLM extracts entities + relationships
await window.jootle.knowledge.writeFact(
  'The 2026 climate summit will be in Sao Paulo in November.',
  'imported from a news article'
);
```

#### `window.jootle.store.*` — per-tool key/value storage

```js
// Set
await window.jootle.store.set('last-search', { q: 'climate', when: Date.now() });

// Get (returns undefined if not set)
const last = await window.jootle.store.get('last-search');

// List keys (optional prefix filter)
const keys = await window.jootle.store.list('prefs.');

// Delete
await window.jootle.store.delete('last-search');
```

Values must be JSON-serializable. Storage is scoped per toolkit slug — different toolkits can use the same key without collision.

#### `window.jootle.link / unlink / getLinks` — relationships

```js
// Link two entities
await window.jootle.link({
  fromType: 'idea',    fromId: 'idea_abc',
  toType:   'project', toId:   'proj_xyz',
  linkType: 'graduated-into'
});

// Unlink
await window.jootle.unlink({ fromType, fromId, toType, toId, linkType });

// Read links (optionally filtered by linkType or targetType)
const links = await window.jootle.getLinks('idea', 'idea_abc', {
  linkType: 'graduated-into'
});
```

#### `window.jootle.documents.*` — bundled reference documents (v1.1)

Only available when your member page belongs to a toolkit that ships `documents[]`. Document slug is unique within the toolkit; toolkit context is auto-injected (no need to pass the toolkit slug yourself).

```js
// Whole document
const doc = await window.jootle.documents.get('gs-spec');
// doc = { slug, name, content_type, size_bytes, content } | null on 404

// Specific section (slash-joined heading path)
const sec = await window.jootle.documents.get('gs-spec', {
  section: 'Part III — The Instruments/Isis Sensing Protocol'
});
// sec = { slug, name, content_type, size_bytes, section: { section_path, heading_level, heading_text, content } }

// Table of contents
const toc = await window.jootle.documents.sections('gs-spec');
// toc = [{ section_path, heading_level, heading_text, section_order }, ...]
```

Heavy methodology / spec content lives in `documents[]`, not `ai_context`. Members and AI tags retrieve sections on demand — pay 2k tokens, not 50k.

#### `window.jootle.navigate(path)` — navigate the parent

```js
window.jootle.navigate('/app/projects/proj_xyz');
```

Only `/app/` paths allowed. Used for "open this entity in its full workspace" links.

#### `window.jootle.theme` and `onThemeChange`

```js
// Current theme snapshot
const t = window.jootle.theme;
// t = { mode: 'dark' | 'light', tokens: { 'accent': '#...', ... } }

// React to theme changes
window.jootle.onThemeChange(snapshot => {
  // Update any baked-in styles
});
```

### Theme tokens you can use

Reference these as `var(--{name})` in your CSS. Don't bake in literal colors.

| Group | Tokens |
| --- | --- |
| Surfaces | `--bg-default`, `--bg-paper`, `--bg-elevated`, `--bg-hover` |
| Accent | `--accent`, `--accent-light`, `--accent-muted`, `--accent-subtle` |
| Text | `--text-primary`, `--text-secondary`, `--text-muted`, `--text-inverse` |
| Borders | `--border-default`, `--border-hover`, `--border-strong` |
| Status | `--success`, `--warning`, `--error`, `--info` |
| Typography | `--font-sans`, `--font-display`, `--font-mono` |
| Spacing | `--space-1` through `--space-8` |
| Radii | `--radius-sm`, `--radius-md`, `--radius-lg`, `--radius-full` |
| Shadows | `--shadow-xs`, `--shadow-sm`, `--shadow-md`, `--shadow-lg` |
| Transitions | `--transition-fast`, `--transition-base`, `--transition-slow` |

Renaming or removing these is a breaking change, so they're stable. Adding new ones is additive.

---

## Anti-patterns and gotchas

- **No outbound network from member HTML.** CSP blocks `fetch('https://...')`. The bridge only reaches `/api/` paths on the same instance.
- **No `<script src="https://...">`.** Same reason. All JS must be inline in the member's HTML.
- **No external fonts via `@font-face`.** Use system fonts via `--font-sans`. CSP blocks font-src too.
- **No directory of files in v1.0.** A toolkit is a *single* `.jtf.json`. There's no concept of multi-file bundles. Don't reference sibling files.
- **No "Adapters" section in v1.1.** Connectors are core-level; you can't ship a custom connector via JTF. (Goals **are** in v1.1 — see the `goals[]` field.)
- **Prefix bundled agent names with your toolkit slug.** A toolkit shipping bare `isis`/`eddie`/`will` will collide with anyone else who picked similar names. Use `idea_isis`, not `isis`. The install path refuses to clobber an agent owned by a different toolkit — collisions surface in the import preview.
- **AI context still pollutes every conversation.** Heavy substance now lives in `documents[]` and `knowledge_seed`. Keep `ai_context` to **orientation** under 2000 words: "you have these tools, this is when to use them, this is how to retrieve substance from documents/KG when needed."
- **Don't reference toolkit data from outside the toolkit.** `entity_instances` can reference `knowledge_seed` entries via `data.knowledge_links` (resolved at runtime), but cross-toolkit references are not supported in v1.1.
- **Documents are reference material, not editable content.** Customers can't edit them via UI in v1.1; re-imports overwrite. If you need customer-editable content, use `entity_instances[]` or an entity type your toolkit defines.
- **Don't ship `knowledge_seed` with thousands of entries unless you mean it.** A 134-entry Register + 900 relationships installs in under a second. 10,000 entries will hurt. If a customer's KG gets dominated by your seed group, they'll struggle to find their own entries — keep seed groups focused and pruned.
- **No raw schema additions.** If you need new tables, you're outside JTF — that's a platform-extension conversation, not a toolkit one.
- **Bridge calls are async.** Every `window.jootle.*` returns a Promise. Use `await` or `.then()`.
- **Tool brain budget is per-tool.** A toolkit that uses `window.jootle.brain.ask` heavily can exhaust the budget; handle 429 gracefully.
- **Entity slug must be globally unique on the instance.** A toolkit that defines `entity_types: [{ slug: "note" }]` will conflict with a "Notes" toolkit shipping `note`. Prefix slugs with toolkit semantics if collisions are likely.
- **AI context pollutes every conversation.** Keep `manifest.ai_context` under 2000 words. Move detail into the human-readable docs.
- **Member HTML cap is 512 KB per page.** If you need more, split into multiple members.
- **`source` field on a playbook is `"seed"` for toolkit-shipped, `"user"` for user-authored.** Use `"seed"` in JTF bundles.
- **Don't auto-install or auto-seed.** Toolkits arrive only via library install or file import. Don't try to bypass.
- **Theme tokens are the *only* sanctioned way to color.** Tokens stay current; literal colors go stale when the host theme changes.

---

## Worked example 1 — Minimal toolkit (Notes)

A single-page toolkit with one entity type, no playbooks. Smallest valid JTF.

```json
{
  "bundle_schema": "1.0",
  "toolkit": {
    "slug": "notes",
    "title": "Notes",
    "description": "Plain notes you can capture and search.",
    "icon": "📝",
    "category": "productivity"
  },
  "manifest": {
    "version": "1.0.0",
    "ai_context": "You have the Notes toolkit. Create notes with [ENTITY: create, type: note, name: <short title>, data: { body: <full text> }]. Notes are short captures, not projects. If a note is becoming substantial, ask the user whether to promote it to a project.",
    "menu": [
      {
        "id": "tk.notes.hub",
        "surface": "sidebar",
        "grp": "tools",
        "label": "Notes",
        "icon": "📝",
        "href": "/app/tools/notes",
        "sort_order": 30,
        "permission": "dashboard"
      }
    ],
    "widgets": []
  },
  "members": [
    {
      "slug": "notes-hub",
      "title": "Notes",
      "description": "All your notes",
      "icon": "📝",
      "sort_order": 0,
      "content": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>body{font-family:var(--font-sans);background:var(--bg-default);color:var(--text-primary);margin:0;padding:var(--space-4)}.note{background:var(--bg-paper);border:1px solid var(--border-default);border-radius:var(--radius-md);padding:var(--space-3);margin-bottom:var(--space-2)}.note b{color:var(--accent)}</style></head><body><h1>Notes</h1><div id=\"list\">Loading…</div><script>(async()=>{const r=await window.jootle.entity.list('note',{limit:100});document.getElementById('list').innerHTML=r.items.length?r.items.map(n=>`<div class=\"note\"><b>${n.name}</b><br>${(n.data&&n.data.body)||''}</div>`).join(''):'<p>No notes yet. Capture one in chat.</p>'})()</script></body></html>"
    }
  ],
  "entity_types": [
    {
      "slug": "note",
      "name": "Note",
      "plural_name": "Notes",
      "icon": "📝",
      "description": "A short note or capture",
      "schema_hint": {
        "fields": [
          { "key": "body", "type": "text", "label": "Body" }
        ],
        "display_name_field": "name"
      }
    }
  ],
  "metadata": {
    "author": "Example Author",
    "publisher_instance": "example",
    "bill_of_materials": {
      "tool_count": 1,
      "widget_count": 0,
      "entity_type_count": 1,
      "entity_types": ["note"],
      "playbook_count": 0,
      "playbooks": [],
      "menu_items": 1,
      "has_goals": false
    }
  }
}
```

Saves as `notes.jtf.json`. Install via **Tools → Import toolkit…**

---

## Worked example 2 — Notes + Summarize playbook

Adds an LLM-powered playbook the AI can invoke.

```json
{
  "bundle_schema": "1.0",
  "toolkit": {
    "slug": "notes",
    "title": "Notes",
    "description": "Plain notes with AI summarization.",
    "icon": "📝",
    "category": "productivity"
  },
  "manifest": {
    "version": "1.1.0",
    "ai_context": "You have the Notes toolkit. Capture notes with [ENTITY: create, type: note, name: <short title>, data: { body: <full text> }]. To summarize a note, run [PLAYBOOK: pb_notes_summarize, note_id: <id>, body: <note body>] — produces a one-sentence summary as an artifact.",
    "menu": [
      {
        "id": "tk.notes.hub",
        "surface": "sidebar",
        "grp": "tools",
        "label": "Notes",
        "icon": "📝",
        "href": "/app/tools/notes",
        "sort_order": 30,
        "permission": "dashboard"
      }
    ],
    "widgets": []
  },
  "members": [
    {
      "slug": "notes-hub",
      "title": "Notes",
      "description": "All your notes",
      "icon": "📝",
      "sort_order": 0,
      "content": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>body{font-family:var(--font-sans);background:var(--bg-default);color:var(--text-primary);margin:0;padding:var(--space-4)}.note{background:var(--bg-paper);border:1px solid var(--border-default);border-radius:var(--radius-md);padding:var(--space-3);margin-bottom:var(--space-2)}.note b{color:var(--accent)}button{background:var(--accent);color:var(--text-inverse);border:0;border-radius:var(--radius-sm);padding:var(--space-1) var(--space-2);cursor:pointer;font-family:var(--font-sans);font-size:.85em}</style></head><body><h1>Notes</h1><div id=\"list\">Loading…</div><script>async function render(){const r=await window.jootle.entity.list('note',{limit:100});document.getElementById('list').innerHTML=r.items.length?r.items.map(n=>`<div class=\"note\"><b>${n.name}</b><br>${(n.data&&n.data.body)||''}<br><button data-id=\"${n.id}\" data-body=\"${(n.data&&n.data.body)||''}\">Summarize</button></div>`).join(''):'<p>No notes yet.</p>';document.querySelectorAll('button').forEach(b=>b.onclick=async e=>{const{id,body}=e.target.dataset;e.target.textContent='Working…';const{run_id}=await window.jootle.playbooks.invoke('pb_notes_summarize',{note_id:id,body},{toolkit_slug:'notes'});e.target.textContent='Submitted ('+run_id+')'})}render()</script></body></html>"
    }
  ],
  "entity_types": [
    {
      "slug": "note",
      "name": "Note",
      "plural_name": "Notes",
      "icon": "📝",
      "description": "A short note or capture",
      "schema_hint": {
        "fields": [{ "key": "body", "type": "text", "label": "Body" }],
        "display_name_field": "name"
      }
    }
  ],
  "playbooks": [
    {
      "playbook": {
        "id": "pb_notes_summarize",
        "name": "Summarize note",
        "description": "Produce a one-line summary of a note",
        "status": "active",
        "source": "seed",
        "category": "standalone",
        "trigger_config": { "triggers": [] },
        "variables_schema": {
          "note_id": { "type": "string", "required": true },
          "body":    { "type": "string", "required": true }
        }
      },
      "nodes": [
        {
          "id": "pb_notes_summarize_step",
          "node_type": "step",
          "label": "Summarize",
          "config": {
            "agent": "documentation",
            "instruction": "Summarize the following note in one short sentence. Be plain — no flourish.\n\n{{body}}\n\nRespond with exactly one line, beginning with:\nOUTPUT:artifact_markdown=<your one-sentence summary>",
            "outputs": ["artifact_markdown"]
          },
          "ordering": 1
        },
        {
          "id": "pb_notes_summarize_end",
          "node_type": "end",
          "label": "Done",
          "ordering": 2
        }
      ],
      "edges": [
        {
          "from_node_id": "pb_notes_summarize_step",
          "to_node_id": "pb_notes_summarize_end",
          "priority": 0
        }
      ]
    }
  ],
  "metadata": {
    "author": "Example Author",
    "publisher_instance": "example",
    "bill_of_materials": {
      "tool_count": 1, "widget_count": 0,
      "entity_type_count": 1, "entity_types": ["note"],
      "playbook_count": 1, "playbooks": ["pb_notes_summarize"],
      "menu_items": 1, "has_goals": false
    }
  }
}
```

---

## Worked example 3 — Multi-member toolkit with enum entity

A small "Decisions" toolkit: a hub page that lists decisions and a detail page for one decision. Decisions have a status enum.

(Pattern only — the full HTML would push past readability here. Use the shape:)

- `toolkit.slug = "decisions"`
- `members`:
  - `{ slug: "decisions-hub", ... }` — lists all decisions, links to detail pages.
  - `{ slug: "decisions-detail", ... }` — reads `?id=...` from URL, renders one decision with its options and criteria.
- `entity_types`:
  - `decision` with fields: `framing` (text), `status` (enum: `open`/`decided`/`retrospective`), `outcome` (text)
  - `decision_option` with fields: `name` (text), `score` (number), `notes` (text)
- `playbooks`:
  - `pb_decisions_retrospect` — invoked 6 months after status flipped to `decided`. Reads the decision + outcome + the world's actual events, scores how the decision aged.
- Cross-linking: `window.jootle.link({ fromType: 'decision', fromId: ..., toType: 'decision_option', toId: ... })` to associate options with their parent decision.

For multi-member toolkits, the toolkit hub page typically navigates to member pages via `window.jootle.navigate('/app/tools/decisions/decisions-detail?id=' + id)` and the detail page reads `new URLSearchParams(window.location.search).get('id')`.

---

## Worked example 4 — v1.1 vertical (custom agent + goal + knowledge seed + document)

A minimal "Reviewer" toolkit that demonstrates each v1.1 field. Trimmed for readability — production toolkits would have richer HTML and more entries.

```json
{
  "bundle_schema": "1.1",
  "toolkit": {
    "slug": "reviewer",
    "title": "Reviewer",
    "description": "Lightweight document-review toolkit demonstrating JTF v1.1.",
    "icon": "🔍",
    "category": "productivity"
  },
  "manifest": {
    "version": "1.0.0",
    "ai_context": "You have the Reviewer toolkit. Submissions are `submission` entities. The `reviewer_pro` agent does deep diagnostic review; invoke it via [PLAYBOOK: pb_reviewer_audit, submission_id: <id>]. Background quality checks run autonomously every 6h. Methodology lives in the `review-spec` document — retrieve specific sections via window.jootle.documents.get('review-spec', { section: '...' }); don't load the whole spec by default. Seeded review-principle entries live in the `reviewer-principles` knowledge group.",
    "menu": [
      { "id": "tk.reviewer.hub", "surface": "sidebar", "grp": "tools",
        "label": "Reviewer", "icon": "🔍", "href": "/app/tools/reviewer",
        "sort_order": 40, "permission": "dashboard" }
    ],
    "widgets": []
  },
  "members": [
    {
      "slug": "reviewer-hub",
      "title": "Reviewer",
      "description": "Submission queue + review status",
      "icon": "🔍",
      "sort_order": 0,
      "content": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><h1>Reviewer</h1><div id=\"out\">Loading…</div><script>(async()=>{const r=await window.jootle.entity.list('submission',{limit:50});const principles=await window.jootle.knowledge.search('review principle',5);const spec=await window.jootle.documents.sections('review-spec');document.getElementById('out').innerHTML='<p>'+r.items.length+' submissions, '+principles.length+' seeded principles, '+spec.length+' spec sections.</p>'})()</script></body></html>"
    }
  ],
  "entity_types": [
    {
      "slug": "submission",
      "name": "Submission",
      "plural_name": "Submissions",
      "icon": "📨",
      "description": "A document submitted for review",
      "schema_hint": {
        "fields": [
          { "key": "body", "type": "text", "label": "Body" },
          { "key": "status", "type": "enum", "label": "Status",
            "options": ["received", "in_review", "approved", "rejected"] }
        ],
        "display_name_field": "name"
      }
    }
  ],
  "playbooks": [
    {
      "playbook": {
        "id": "pb_reviewer_audit",
        "name": "Audit submission",
        "description": "Run a deep diagnostic audit on a submission",
        "status": "active", "source": "seed", "category": "standalone",
        "trigger_config": { "triggers": [] },
        "variables_schema": { "submission_id": { "type": "string", "required": true } }
      },
      "nodes": [
        {
          "id": "pb_reviewer_audit_step", "node_type": "step", "label": "Audit",
          "config": {
            "agent": "reviewer_pro",
            "instruction": "Audit submission {{submission_id}} against the review spec. Reference seeded principles where relevant. Produce a single-paragraph verdict.\n\nOUTPUT:artifact_markdown=<your verdict>",
            "outputs": ["artifact_markdown"]
          },
          "ordering": 1
        },
        { "id": "pb_reviewer_audit_end", "node_type": "end", "label": "Done", "ordering": 2 }
      ],
      "edges": [
        { "from_node_id": "pb_reviewer_audit_step", "to_node_id": "pb_reviewer_audit_end", "priority": 0 }
      ]
    }
  ],
  "agents": [
    {
      "name": "reviewer_pro",
      "description": "You are Reviewer-Pro, a meticulous document auditor. You evaluate submissions against the Reviewer toolkit's methodology (loaded from the `review-spec` document) and the seeded review principles. Voice: precise, unsentimental, citation-oriented.\n\n## Protocol\n\n1. Retrieve relevant sections of the review spec on demand.\n2. Cite seeded principles by name when applicable.\n3. Produce a single-paragraph verdict with a confidence score.",
      "budget_profile": "deep"
    }
  ],
  "goals": [
    {
      "title": "Stale submissions patrol",
      "description": "Flag submissions sitting in_review for >7 days.",
      "rules": {
        "trigger": "schedule",
        "agent": "reviewer_pro",
        "context_query": "type=submission AND status=in_review AND updated_at < now() - 7d"
      },
      "schedules": { "cron": "0 9 * * *", "timezone": "UTC" },
      "reporting_cadence": "weekly",
      "status": "active"
    }
  ],
  "knowledge_seed": {
    "group_id": "reviewer-principles",
    "entities": [
      {
        "name": "Citation-Density Principle",
        "entity_type": "concept",
        "summary": "Strong reviews cite specific lines, not vibes. Density of citation per claim is the canonical signal of seriousness.",
        "attributes": { "tier": "core" }
      },
      {
        "name": "Scope-Match Check",
        "entity_type": "concept",
        "summary": "A review must operate within the submission's declared scope; finding flaws outside that scope is interesting but not load-bearing.",
        "attributes": { "tier": "core" }
      }
    ],
    "relationships": [
      {
        "source": "Citation-Density Principle",
        "target": "Scope-Match Check",
        "type": "complemented_by",
        "fact": "Citation density bounded by declared scope keeps reviews actionable.",
        "weight": 0.7
      }
    ]
  },
  "entity_instances": [
    {
      "entity_type": "submission",
      "name": "Example submission template",
      "data": {
        "body": "A placeholder so reviewers see the empty-queue shape on day one.",
        "status": "received",
        "knowledge_links": ["Citation-Density Principle"]
      }
    }
  ],
  "documents": [
    {
      "slug": "review-spec",
      "name": "Reviewer Methodology Spec",
      "content_type": "text/markdown",
      "content": "# Reviewer Methodology\n\n## Part I — Principles\n\nAll reviews must respect citation density and scope-match.\n\n## Part II — Procedure\n\n### Intake\n\n1. Confirm submission has a declared scope.\n2. Stamp received_at.\n\n### Audit pass\n\n1. Retrieve relevant principles via the knowledge bridge.\n2. Cite by name in the verdict.\n\n## Part III — Verdict format\n\nOne paragraph. Plain prose. Confidence as a 0..1 number at the end."
    }
  ],
  "metadata": {
    "author": "Example Author",
    "publisher_instance": "example",
    "bill_of_materials": {
      "tool_count": 1, "widget_count": 0,
      "entity_type_count": 1, "entity_types": ["submission"],
      "playbook_count": 1, "playbooks": ["pb_reviewer_audit"],
      "menu_items": 1, "has_goals": true,
      "agent_count": 1, "goal_count": 1,
      "entity_instance_count": 1,
      "knowledge_seed_entity_count": 2,
      "knowledge_seed_relationship_count": 1,
      "document_count": 1, "document_total_bytes": 396
    }
  }
}
```

What this demonstrates:

- A custom agent (`reviewer_pro`) the toolkit ships, with a `deep` budget hint and a multi-paragraph system prompt that won't pollute every conversation.
- A goal that runs autonomously on cron, using the custom agent.
- A two-entity knowledge seed with one relationship, queryable via `window.jootle.knowledge.search` and `.get`.
- A seed entity instance that soft-links to the knowledge seed via `data.knowledge_links`.
- A methodology document with three sections, retrievable section-by-section via `window.jootle.documents.get('review-spec', { section: 'Part II — Procedure/Audit pass' })`.

The `ai_context` stays under 100 words — substance lives in the document and the KG.

---

## Validation checklist

Before delivering a `.jtf.json` to a customer, run through this list:

**Structure**
- [ ] `bundle_schema` is `"1.0"` (v1.0-only fields) OR `"1.1"` (if you use any v1.1 field: `agents`, `goals`, `entity_instances`, `knowledge_seed`, `documents`).
- [ ] `toolkit.slug`, `toolkit.title`, `toolkit.icon`, `toolkit.category` all present.
- [ ] `manifest.version` follows semver (e.g., `"1.0.0"`).
- [ ] `manifest.ai_context` is non-empty and under 2000 words.
- [ ] `members[]` has at least one entry.
- [ ] Each member has `slug`, `title`, `content`, `sort_order`.
- [ ] If `entity_types[]` present, each has unique `slug` and valid field types (`text`/`enum`/`number`/`boolean`/`json`).
- [ ] If `playbooks[]` present, every node has an `id`, `node_type`, and `ordering`; every edge references existing node IDs.
- [ ] `metadata.bill_of_materials` counts match the actual content (including v1.1 counts when applicable).

**v1.1 fields (skip if your bundle is v1.0)**
- [ ] If `agents[]` present, each has `name` (prefixed with toolkit slug) and `description`. `budget_profile` (if set) is one of `fast`/`balanced`/`deep`/`thinking`.
- [ ] If `goals[]` present, each has `title` (unique within bundle), `rules`, and references only agents/playbooks shipped in this same bundle (or core ones).
- [ ] If `entity_instances[]` present, each `entity_type` refers to either this bundle's `entity_types[]` or a core type. Each has `name` and `data`.
- [ ] If `knowledge_seed` present, `group_id` follows `<toolkit-slug>-<purpose>` convention. Every relationship's `source` and `target` names match an entry in `knowledge_seed.entities[]`.
- [ ] If `documents[]` present, each has unique `slug` (within this toolkit), `name`, `content_type`, and `content`. No document exceeds 2 MB.
- [ ] If you use any v1.1 field, `bundle_schema` is `"1.1"` (not `"1.0"`).

**Content quality**
- [ ] Member HTML uses theme tokens (`var(--accent)`, etc.) rather than literal colors.
- [ ] No `<script src="https://...">`, no `fetch('https://...')`, no `@font-face` with external URLs.
- [ ] No outbound API calls outside the `window.jootle.*` bridge.
- [ ] All bridge calls handle errors (especially `429` budget exhaustion for `brain.*`).
- [ ] AI context teaches the AI **what** the toolkit does, **when** to use it, and **how** to invoke it (tags/playbooks). Not duplicated documentation.
- [ ] Menu entries point to valid `/app/` paths.
- [ ] No member HTML exceeds 512 KB.

**Naming hygiene**
- [ ] Toolkit slug is kebab-case and unique within likely-installed toolkits.
- [ ] Entity slugs are scoped to avoid collisions (`governance_session`, not just `session`).
- [ ] Playbook IDs prefixed with `pb_{toolkit_slug}_` for traceability.
- [ ] Menu IDs prefixed with `tk.{toolkit_slug}.` for traceability.

**Test plan**
- [ ] Import on a fresh test instance and verify all menus appear correctly.
- [ ] Open each member page and verify it loads, fetches data, and respects theme.
- [ ] Invoke each playbook and verify it produces the expected output.
- [ ] Toggle the instance theme between dark and light; verify pages re-theme.
- [ ] Test with the AI: ask it to do the toolkit's primary task; verify it discovers the toolkit via `ai_context`.

---

## How to deliver

Output the bundle as a single JSON file named `{toolkit-slug}.jtf.json`. The customer installs it via:

**Tools → Import toolkit… → Select file → Review preview → Install**

The preview dialog will show the bill-of-materials, any conflicts (slug collisions with existing toolkits), and prompt for confirmation. File imports require the `admin` permission on the instance.

For library publication (reviewed, listed in the storefront), the toolkit author submits via **Tools → Submit to library** from their own instance — that triggers the same JTF generation path under the hood. The control-plane review process is documented separately and isn't required for private delivery.

---

## When to ask the user before generating

Before producing a complete `.jtf.json`, get clear answers to:

1. **What problem does the toolkit solve?** One sentence.
2. **What entities does it track?** (Or does it only use core entities?)
3. **What workflows should the AI automate?** (Each one becomes a playbook.)
4. **What UI surface does the user need?** A hub page? Multiple pages? Just a sidebar entry?
5. **Are there any external systems involved?** (If yes, flag that connectors are a separate platform extension and the toolkit will need to work with what's already configured.)

If any of these are unclear, ask before generating. A toolkit produced from vague requirements wastes the user's review time and yours.

---

## Reference notes

- **JTF v1.1 design doc (authoritative):** `control-plane/docs/jtf-v1.1-design.md` (internal — has the full design rationale and decisions).
- **JTF v1.0 spec:** `control-plane/docs/jootle-toolkit-format.md` (internal — the v1.0 baseline that v1.1 extends additively).
- **Bridge implementation:** `app/interfaces/frontend/src/hooks/useIframeBridge.ts` (internal — this skill mirrors its API surface).
- **v1.2+ planned:** binary documents (PDFs, images), cross-toolkit references, signed bundles, structured `ai_resources` block (auto-orientation), CLI tooling, JSON Schema file. Stay on v1.1 until v1.2 ships.
- **Forward compatibility:** older instances reject `bundle_schema` values they don't recognize. v1.1 instances accept both `"1.0"` and `"1.1"`; v1.0 instances reject `"1.1"`. Check the customer's instance version before shipping `"1.1"`.
- **This skill versioned with JTF v1.1** — if/when JTF v2.0 ships, this skill will be republished as `jootle-toolkit-builder-v2`.
