Example: Knowledge-base tool (read + write the user's Trove)
Most toolkits reach out to a third-party API. This one reaches in — to the calling user’s own Trove knowledge base — using the ctx.trove client. It exposes two tools:
find_notes— semantic search over the user’s KB (ctx.trove.search), read-only.save_note— write a quick note into the KB (ctx.trove.ingest), mutating.
ctx.trove is present only when the manifest grants the matching scope, so this is the canonical example of scopes: ["trove:search", "trove:ingest"] and how the SDK’s read-only derivation reacts to a write scope.
Project layout
Section titled “Project layout”my-notes/ extension.ts ← the whole toolkit manifest.json ← generated from itextension.ts
Section titled “extension.ts”No secrets and no egress — this toolkit never leaves Trove. The only capabilities it asks for are the two scopes, and they do double duty: they are what makes ctx.trove present, and the SDK reads them to derive each tool’s readOnlyHint. Because the toolkit declares trove:ingest, its tools default to readOnlyHint: false — so the read tool sets readOnlyHint: true back explicitly, and the write tool sets mutating: true.
import { defineToolkit, tool, z, ToolError } from "@ontrove/extend/toolkit";
export default defineToolkit({ id: "my-notes", name: "My Notes", description: "Search and save quick notes in your Trove knowledge base.", icon: "🗒️", version: "1.0.0", scopes: ["trove:search", "trove:ingest"], tools: [ tool({ name: "find_notes", title: "Find notes", description: "Semantically search the user's Trove knowledge base and return the most " + "relevant documents. Use when the user asks what they've read or saved about a topic.", // The server declares a write scope, so the default would be readOnlyHint:false. // This tool only reads, so we override. annotations: { readOnlyHint: true }, input: z.object({ query: z.string().min(1).describe("What to search for."), limit: z.number().int().min(1).max(20).default(5).describe("Max results."), }), output: z.object({ results: z.array( z.object({ id: z.string(), title: z.string(), snippet: z.string(), score: z.number(), author: z.string().optional(), }), ), }), async handler({ query, limit }, ctx) { // ctx.trove is present only because the manifest granted trove:search. if (!ctx.trove) { throw new ToolError("Trove search is not available for this server.", { retryable: false, }); } // search(query, opts?) — query is positional. const results = await ctx.trove.search(query, { limit });
if (results.length === 0) { return { text: `No notes found for "${query}".`, structured: { results: [] } }; } const lines = results .map((r) => ` [${r.score.toFixed(2)}] ${r.title} — ${r.snippet}`) .join("\n"); return { text: `${results.length} note(s) for "${query}":\n${lines}`, structured: { results }, }; }, }), tool({ name: "save_note", title: "Save note", description: "Save a short note into the user's Trove knowledge base so it's searchable later. " + "Use when the user says 'save this', 'note that', or 'remember…'.", // A write tool: mark it mutating so the client surfaces consent. mutating: true, input: z.object({ title: z.string().min(1).describe("A short title for the note."), text: z.string().min(1).describe("The full note body to index."), url: z.string().url().optional().describe("Optional source URL."), }), output: z.object({ ingested: z.number() }), async handler({ title, text, url }, ctx) { if (!ctx.trove) { throw new ToolError("Trove ingest is not available for this server.", { retryable: false, }); } // ingest takes TroveIngestDoc documents; only `title` is required. const result = await ctx.trove.ingest([ { title, text, ...(url ? { url } : {}), author: "My Notes" }, ]); return { text: `Saved "${title}" (${result.ingested} document indexed).`, structured: { ingested: result.ingested }, }; }, }), ],});The generated manifest.json beside it:
{ "id": "my-notes", "name": "My Notes", "description": "Search and save quick notes in your Trove knowledge base.", "icon": "🗒️", "version": "1.0.0", "scopes": ["trove:search", "trove:ingest"], "generated": true}Why this shape
Section titled “Why this shape”scopes: ["trove:search", "trove:ingest"], declared once. Without themctx.troveisundefined, which is why both handlers guard before using it.find_notesoverrides toreadOnlyHint: true. Because the toolkit declares a write scope, the SDK’s default for every tool isreadOnlyHint: false. The read tool sets it back totrueso clients don’t add needless confirmation friction.save_notesetsmutating: true. The SDK keepsreadOnlyHint: falsefor it, and a tool that is not read-only is one a client is expected to confirm before calling.ctx.trove.search(query, opts)takes the query positionally;ctx.trove.ingest(docs)takesTroveIngestDocdocuments and returns{ ingested }. Onlytitleis required — a doc can also carry afileUrlto capture, and afeedto group by. This toolkit’s notes are all one flat list, so it declares no feed.- No
egress, nosecrets. This toolkit only talks to Trove, so it declares neither.
Deploy
Section titled “Deploy”trove toolkit deploy# ✓ deployed my-notes (version 1.0.0, building)# my-notes__find_notes# my-notes__save_noteNothing else to configure — there are no secrets, and the scopes are granted by the manifest at deploy time.
What the user sees in Claude
Section titled “What the user sees in Claude”Remember that the Q3 board meeting moved to the 14th.
Claude calls my-notes__save_note({ title: "Q3 board meeting date", text: "Moved to the 14th." }) — and, because the tool is mutating, the client surfaces a confirmation before the write. Later:
What did I note about the Q3 board meeting?
Claude calls my-notes__find_notes({ query: "Q3 board meeting" }), which runs semantically over the user’s KB via ctx.trove.search and returns the saved note.
Next steps
Section titled “Next steps”- NASA APOD — an authed server that reaches an external API
- Hacker News source adapter — the other authoring direction: collecting data in
- SDK Reference →
ctx.trove— the full knowledge-base client