piebird-kagi-search's mod: the Kagi tools, and WebSearch kept out of the way as the search setting says

Plugins for coding agents: omp first, Claude Code and Codex where a plugin works there too, and the Tern terminal.
Each plugin's README shows it working in a short video.
| Plugin | What it gives you | Works in |
|---|---|---|
| piebird-kagi-search | Kagi web search, quick answers and Kagi News with your own Kagi account, from the plugin's own program; the kagi skill; a setting for how much the agent relies on Kagi over its own web search | omp, Claude Code, Codex, ChatGPT, Claude |
| piebird-agent-variants | The agent_variant_setup tool: temporary project-scoped variants of an agent, with explicit model routing | omp |
| piebird-sessions | The session_read tool: reads, pages and searches a session journal (omp's, Claude Code's or Codex's) by session id and line without opening it whole, with asks shown in full (each question, its options and what was picked or typed); and a line telling the agent its own session id | omp, Claude Code, Codex, ChatGPT, Claude |
| piebird-tern-bridge | The tern_lua tool, running Lua with Tern's window API in the Tern window holding omp's pane, and the tern skill | omp in Tern |
| piebird-threads | Your project's open threads in front of the agent: the files in the folder its settings name, in omp before a prompt whenever the list changes and shown in the chat, in Claude Code at the start of a conversation; a rule pointing to the threads skill, which gives the few parts of a thread file the list reads and leaves the rest to the agent | omp, Claude Code, Codex, ChatGPT |
| piebird-workflow-rules | Rules on how to work (proportion, thinking before building, fixing corrections as classes, project tooling, changelogs, asking for decisions) and the failure-classes skill | omp, Claude Code, Codex, ChatGPT |
| piebird-tern-ghostty-style | omp's chat in Tern drawn the way omp looks in Ghostty: one box per tool call, thoughts left open, bash as $ command, compact diffs and replies | omp in Tern |
On a Mac or a Windows PC with ChatGPT or Claude, download the installer from the releases page (piebird-<version>-macos-arm64.dmg for Apple silicon, -macos-x64.dmg for an Intel Mac, -windows-x64.exe for Windows) and open it. It lists the plugins and the apps on your computer they go to; pick the plugins and click Install. It finds omp, Claude Code and Codex too. docs/installer.md has what it writes where.
In a terminal, add this repository as a marketplace once, then install the plugins you want. piebird-kagi-search needs its vault, a program a marketplace can't carry, so it comes with the installer.
omp:
omp plugin marketplace add georgelubaretsi/piebird omp plugin install piebird-sessions@piebird
Claude Code:
claude plugin marketplace add georgelubaretsi/piebird claude plugin install piebird-threads@piebird
Codex:
codex plugin marketplace add georgelubaretsi/piebird codex plugin add piebird-threads@piebird
Codex runs a plugin's program (piebird-kagi-search's, piebird-sessions') only as the installer sets it up, so those two come to Codex through the installer.
A plugin that works in Tern carries a Tern plugin too (its so.stencil.tern/ folder), and omp brings it along: when omp starts in a Tern pane, it installs that Tern plugin if Tern lacks it, updates it after omp plugin upgrade, and says so in the chat. There is nothing to install in Tern yourself. A Tern plugin turned off in Tern's settings is left alone.
Issues and pull requests are welcome. This repository is exported from a private workspace, so a pull request is applied there by hand, with credit to its author, and arrives here as a new commit rather than as a merge.
Everything runs in the dev shell (Nix):
nix develop bun install bun run check # generated files, lint, format, typecheck, tests
AGENTS.md has the layout, commands and conventions. Tern's docs are not redistributed here; bun tools/sync-references.ts tern fetches them into reference/.
MIT. reference/agent-plugins/ is the Agent Plugins specification and schemas, redistributed under their own licenses (NOTICE).
hooks/register.ts 133 lines1// piebird-kagi-search's Claude Code part, a mod: the Kagi tools, performed by the plugin's vault,
2// and the "search" setting (core/search.ts) applied to Claude Code's built-in WebSearch. While it
3// should stay out of the way it waits behind tool search, and a call to it is refused with a word
4// on what to use instead; it also runs while the vault isn't there. The model's instructions about
5// searching are a block in its context. A hook that fails is skipped, so a broken mod lets
6// WebSearch run.
7import type { Api, Register } from "claude-code";
8import type { Io, Place } from "../../kit/core/io";
9import type { Search } from "../../core/search";
10import { contextBlock } from "../../kit/claude/context";
11import { FS_READ_LIMIT, printerOf, stdoutPieces } from "../../kit/claude/pieces";
12import { dispatch, toolArguments, toolNames, toolSpecs } from "../../kit/claude/tools";
13import { vaultPath } from "../../kit/core/vault";
14import { handlersFor } from "../../core/handlers";
15import { model } from "../../core/model.gen";
16import { operations } from "../../core/operations.gen";
17import { builtInOff, instructions, PLUGIN, readSearch, VAULT } from "../../core/search";
18
19/** Claude Code's built-in search tool. */
20const WEB_SEARCH = "WebSearch";
21
22/** Whether a Kagi call has failed in this conversation, which brings WebSearch back with "first". */
23let kagiFailed = false;
24/** The "search" setting, read once per conversation. */
25let search: Promise<Search> | undefined;
26
27/** Core's file reads, folder listings and program runs, made of this session's `$`. */
28function ioOf($: Api): Io {
29 return {
30 async readText(path) {
31 try {
32 return await $.fs.read(path);
33 } catch (error) {
34 // $.fs.read rejects a missing file as it does any other failure ("… failed: ENOENT").
35 if (!(await $.fs.exists(path))) return undefined;
36 throw error;
37 }
38 },
39 async readPieces(path) {
40 if (!(await $.fs.exists(path))) return undefined;
41 // Nothing is read or spawned until the first piece is asked for.
42 return (async function* () {
43 if ((await $.fs.stat(path)).size < FS_READ_LIMIT) {
44 yield await $.fs.read(path);
45 return;
46 }
47 const printer = printerOf(path, (await $.env.get("OS")) === "Windows_NT");
48 yield* stdoutPieces($.process.spawn(printer), printer);
49 })();
50 },
51 async list(path) {
52 try {
53 return (await $.fs.list(path)).map(entry => ({
54 name: entry.name,
55 directory: entry.kind === "dir",
56 modified: entry.kind === "file" ? entry.mtimeMs : 0,
57 }));
58 } catch (error) {
59 if (!(await $.fs.exists(path))) return undefined;
60 throw error;
61 }
62 },
63 // Rejects when the program can't start or outlives timeoutMs (default 30 s, at most 10 min).
64 run: (argv, options) => $.process.run(argv, { ...options }),
65 };
66}
67
68/** The person's home, the session's project root (it follows /cd), and the plugin's folder. */
69async function placeOf($: Api): Promise<Place> {
70 const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
71 if (home === undefined) throw new Error("neither HOME nor USERPROFILE is set");
72 return { home, project: await $.session.root(), plugin: $.plugin.root };
73}
74
75async function vaultOf($: Api): Promise<string> {
76 return vaultPath($.plugin.root, VAULT, (await $.env.get("OS")) === "Windows_NT");
77}
78
79/**
80 * The setting, given Claude Code's own choice (read from the person's settings only, never a
81 * project's); "offered" when it can't be read, so nothing is held back.
82 */
83function setting($: Api, choice: unknown): Promise<Search> {
84 search ??= placeOf($)
85 .then(place => readSearch(ioOf($), place, { person: choice }))
86 .catch((): Search => "offered");
87 return search;
88}
89
90/** Whether Kagi can't be used here: a call failed, or the plugin has no vault built. */
91async function kagiOut($: Api): Promise<boolean> {
92 return kagiFailed || !(await $.fs.exists(await vaultOf($)));
93}
94
95export const register: Register = (on, options) => {
96 on("session.start", async ($, e, next) => {
97 for (const spec of toolSpecs(operations)) await $.tool.register(spec);
98 return next(e);
99 });
100 // /clear, /resume and /branch end the conversation without reloading the module: the next one
101 // starts over, maybe in another project.
102 on("session.end", async ($, e, next) => {
103 kagiFailed = false;
104 search = undefined;
105 $.ui.invalidate("tool.describe");
106 return next(e);
107 });
108 on("prompt.context", async ($, e, next) => {
109 const r = await next(e);
110 const block = contextBlock(PLUGIN, instructions(await setting($, options.search)));
111 return { ...r, blocks: [...r.blocks, block] };
112 });
113 on("tool.call", { tool: toolNames(PLUGIN, operations) }, async ($, e, next) => {
114 const handlers = handlersFor(ioOf($), await vaultOf($), () => {
115 if (kagiFailed) return;
116 kagiFailed = true;
117 // Claude Code asks tool.describe again, which puts WebSearch back where it was.
118 $.ui.invalidate("tool.describe");
119 });
120 return (await dispatch(operations, handlers, e.tool, toolArguments(e))) ?? next(e);
121 });
122 on("tool.describe", { tool: WEB_SEARCH }, async ($, e, next) => {
123 const described = await next(e);
124 const off = builtInOff(await setting($, options.search), await kagiOut($));
125 return off ? { ...described, isDeferred: true } : described;
126 });
127 on("tool.call", { tool: WEB_SEARCH }, async ($, e, next) => {
128 const value = await setting($, options.search);
129 if (!builtInOff(value, await kagiOut($))) return next(e);
130 return { deny: model.text("refused-web-search", { search: value }).text };
131 });
132};
133../kit/core/io.ts 53 lines1// What a shell lends core code that needs the outside world: reading a file, whole or in pieces,
2// listing a folder and running a program. Each shell makes one from its own API (kit/bun: Bun; a
3// Claude Code mod: `$`, in its hooks module, where `$` calls must stay), so the code that uses it
4// stays pure.
5import type { CallSignal } from "./operations";
6
7export interface RunResult {
8 readonly exitCode: number;
9 readonly stdout: string;
10 readonly stderr: string;
11}
12
13export interface RunOptions {
14 /** Written to the program's stdin, then closed. */
15 readonly stdin?: string;
16 readonly timeoutMs?: number;
17 /** The program is killed when it aborts: the call that started it was cancelled. */
18 readonly signal?: CallSignal;
19}
20
21/** One entry of a folder. */
22export interface IoEntry {
23 readonly name: string;
24 readonly directory: boolean;
25 /** A file's last modification, milliseconds since the epoch; 0 for anything else. */
26 readonly modified: number;
27}
28
29export interface Io {
30 /** The file's text; undefined when there is no such file. Other failures throw. */
31 readText(path: string): Promise<string | undefined>;
32 /**
33 * The file's text in pieces as it is read, for a file too big to hold at once or read only in
34 * part: reading starts with the first piece asked for, and leaving the loop early stops it.
35 * Undefined when there is no such file; other failures throw while reading.
36 */
37 readPieces(path: string): Promise<AsyncIterable<string> | undefined>;
38 /** The folder's entries; undefined when there is no such folder. Other failures throw. */
39 list(path: string): Promise<readonly IoEntry[] | undefined>;
40 /** Runs argv[0] with the rest as arguments, without a shell. */
41 run(argv: readonly string[], options?: RunOptions): Promise<RunResult>;
42}
43
44/** Where the shell runs: the folders core code may need, as absolute paths. */
45export interface Place {
46 /** The person's home folder. */
47 readonly home: string;
48 /** The project the agent works in; settings files and paths in them are relative to it. */
49 readonly project: string;
50 /** The plugin's own folder, where it is installed. */
51 readonly plugin: string;
52}
53../core/search.ts 43 lines1// The "search" setting (core/settings.json) and what follows from it in every agent: what the model
2// is told, and whether the agent's own web search stays out of the way. With "only" it always
3// does; with "first" until a Kagi call fails in the session (kagiDown, core/errors.ts), so the
4// agent searches with Kagi and never has to fail a call to learn it; with "offered" never.
5import type { Io, Place } from "../kit/core/io";
6import type { ModelText } from "../kit/core/model";
7import type { AgentChoice } from "../kit/core/settings";
8import { agentChoices, readSettings } from "../kit/core/settings";
9import { model } from "./model.gen";
10import { declared } from "./settings.gen";
11
12export const PLUGIN = "piebird-kagi-search";
13
14/** The vault's program name (catalog.json `program`). */
15export const VAULT = "kagi-vault";
16
17export type Search = "only" | "first" | "offered";
18
19function isSearch(value: unknown): value is Search {
20 return value === "only" || value === "first" || value === "offered";
21}
22
23/**
24 * The setting where the agent works at `place`, given the agent's own choices when it has a
25 * settings screen ("shared", none or a value that isn't a choice follow the settings files). A
26 * project's choice ranks with the project's file: a repo can't override the person.
27 */
28export async function readSearch(io: Io, place: Place, choice?: AgentChoice): Promise<Search> {
29 const { values } = await readSettings(io, place, { plugin: PLUGIN, declared, ...agentChoices("search", choice) });
30 const value = values.search?.value;
31 return isSearch(value) ? value : "first";
32}
33
34/** Whether the agent's own web search stays out of the way, given whether Kagi failed in the session. */
35export function builtInOff(search: Search, kagiFailed: boolean): boolean {
36 return search === "only" || (search === "first" && !kagiFailed);
37}
38
39/** What the model is told about searching. */
40export function instructions(search: Search): ModelText {
41 return model.text("instructions", { search });
42}
43../kit/claude/context.ts 17 lines1// What a plugin tells the model at the start of a conversation, in a Claude Code mod: a block its
2// `prompt.context` hook adds to the context of the first user message, which Claude Code renders
3// under `# <name>` and computes again after compaction or /clear. The build copies kit/ into each
4// plugin that needs it (plugins/<name>/kit/); edit it in kit/.
5import type { ModelText } from "../core/model";
6
7/** One block of `prompt.context`; its name must be unique among the conversation's blocks. */
8export interface ContextBlock {
9 readonly name: string;
10 readonly text: string;
11}
12
13/** The block `name` with the model's text, for the hook to add to `(await next(e)).blocks`. */
14export function contextBlock(name: string, text: ModelText): ContextBlock {
15 return { name, text: text.text };
16}
17../kit/claude/pieces.ts 55 lines1// A file read in pieces in a Claude Code mod (`Io.readPieces`, kit/core/io.ts): `$.fs.read` holds
2// at most 4 MiB, so a bigger file is printed by a program the mod spawns and read as it comes. The
3// hooks module makes the `$` calls (kit/claude/io-of.md); this turns what they give into pieces.
4// The build copies kit/ into each plugin that needs it (plugins/<name>/kit/); edit it in kit/.
5
6/**
7 * Files below this size are read with `$.fs.read`. It refuses one over 4 MiB, and a file may grow
8 * between the size check and the read (an agent's own journal does), hence the margin.
9 */
10export const FS_READ_LIMIT = 3 * 1024 * 1024;
11
12/** What `$.process.spawn` gives: the program's output in pieces, then how it ended. */
13export interface Spawned extends AsyncIterable<{ readonly stream: "stdout" | "stderr"; readonly text: string }> {
14 readonly result: Promise<{ readonly code: number | null; readonly signal: string | null }>;
15}
16
17/** A program to spawn: its command line and the variables set for it. */
18export interface Printer {
19 readonly argv: readonly string[];
20 readonly env?: Readonly<Record<string, string>>;
21}
22
23/** Where the Windows printer finds its file: a variable, which no shell parses. */
24const FILE_VARIABLE = "PIEBIRD_PRINT_FILE";
25
26/** PowerShell copying the file's bytes to standard output as they are. */
27const COPY_TO_STDOUT = `$f=[IO.File]::OpenRead($env:${FILE_VARIABLE});$o=[Console]::OpenStandardOutput();$f.CopyTo($o);$o.Flush();$f.Close()`;
28
29/**
30 * The program that prints a file on this system: `cat`, or PowerShell on Windows. The path may
31 * come from the model, so on Windows it goes in a variable: on cmd's command line, a file named
32 * `x&calc&.jsonl` would run calc.
33 */
34export function printerOf(path: string, windows: boolean): Printer {
35 if (!windows) return { argv: ["cat", path] };
36 return {
37 argv: ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", COPY_TO_STDOUT],
38 env: { [FILE_VARIABLE]: path },
39 };
40}
41
42/**
43 * The program's standard output in pieces as it writes them. Leaving the loop early stops the
44 * program; one that fails throws with what it wrote to standard error.
45 */
46export async function* stdoutPieces(spawned: Spawned, printer: Printer): AsyncGenerator<string> {
47 let stderr = "";
48 for await (const piece of spawned) {
49 if (piece.stream === "stdout") yield piece.text;
50 else stderr += piece.text;
51 }
52 const { code, signal } = await spawned.result;
53 if (code !== 0) throw new Error(`${printer.argv[0]} failed (${signal ?? `exit ${code}`}): ${stderr.trim()}`);
54}
55../kit/claude/tools.ts 88 lines1// A plugin's operations that the model gets as tools, in a Claude Code mod: the specs its hooks
2// module registers with `$.tool.register` at session.start, and the answer its `tool.call` hook
3// gives, from core's handler. No `$` here: Claude Code refuses a module that passes `$` to another
4// file, so the hooks module makes every call and hands values in and out (kit/claude/io-of.md).
5// The build copies kit/ into each plugin that needs it (plugins/<name>/kit/); edit it in kit/.
6import type { Handlers, Operations, Outcome } from "../core/operations";
7import { crashText } from "../core/operations";
8
9/** What `$.tool.register` takes (Claude Code's `ToolSpec`). */
10export interface ToolSpec {
11 /** The short name; the model calls it as `mcp__<plugin>__<name>`. */
12 readonly name: string;
13 readonly description: string;
14 readonly inputSchema: Readonly<Record<string, unknown>>;
15 /** True: the tool waits behind tool search; false: its schema is in every prompt. */
16 readonly isDeferred: boolean;
17}
18
19/**
20 * A `tool.call` hook's answer. Claude Code 2.1.296 gives the model `result` (a string) as the
21 * tool's result and `deny` as an error result (`is_error`); it ignores `text` and `isError` on a
22 * hook's own answer and refuses a `result` that is neither a string nor content blocks.
23 */
24export type CallResult = { readonly result: string } | { readonly deny: string };
25
26/** The fields of a `tool.call` event that are Claude Code's, not the tool's arguments. */
27const RESERVED: Readonly<Record<string, true>> = {
28 tool: true,
29 tool_use_id: true,
30 agentId: true,
31 requestMeta: true,
32 consent: true,
33};
34
35/** One spec per operation the model gets as a tool, in declaration order. */
36export function toolSpecs(operations: Operations): ToolSpec[] {
37 return operations.tools.map(({ tool }) => ({
38 name: tool.name,
39 description: tool.description.text,
40 inputSchema: tool.inputSchema,
41 isDeferred: tool.hints.alwaysLoad !== true,
42 }));
43}
44
45/** The name the model and `tool.call` hooks see for the plugin's tool `name`. */
46export function toolName(plugin: string, name: string): string {
47 return `mcp__${plugin}__${name}`;
48}
49
50/** Every tool's full name, for the `tool.call` hook's matcher. */
51export function toolNames(plugin: string, operations: Operations): string[] {
52 return operations.tools.map(({ tool }) => toolName(plugin, tool.name));
53}
54
55/** The tool's arguments in a `tool.call` event, which carries them beside its own fields. */
56export function toolArguments(event: Readonly<Record<string, unknown>>): Record<string, unknown> {
57 return Object.fromEntries(Object.entries(event).filter(([key]) => !Object.hasOwn(RESERVED, key)));
58}
59
60/** The hook's answer for an outcome: its text, as a result or, for a declared error, a refusal. */
61export function callResult(outcome: Outcome): CallResult {
62 return outcome.ok ? { result: outcome.text.text } : { deny: outcome.text.text };
63}
64
65/**
66 * Performs the call to the tool `tool` (its full name) with its arguments `input`: core's handler
67 * for the operation answers, and a handler that throws answers with the catalog's error-crashed.
68 * Undefined when no operation is that tool, for the hook to pass the call on.
69 */
70export async function dispatch(
71 operations: Operations,
72 handlers: Handlers,
73 tool: string,
74 input: unknown,
75): Promise<CallResult | undefined> {
76 // Tool names never hold `__` (tools/operations.schema.json), so the last one ends the plugin's.
77 const at = tool.lastIndexOf("__");
78 const name = tool.startsWith("mcp__") && at > "mcp_".length ? tool.slice(at + 2) : undefined;
79 const op = operations.tools.find(o => o.tool.name === name);
80 if (op === undefined) return undefined;
81 try {
82 if (!Object.hasOwn(handlers, op.id)) throw new Error(`no handler for operation ${op.id}`);
83 return callResult(await handlers[op.id](input));
84 } catch (error) {
85 return { deny: crashText(operations, error).text };
86 }
87}
88../kit/core/vault.ts 67 lines1// Calling a plugin's vault: the signed native program that holds its secrets and performs the
2// operations that need one (core/operations.json, `vault: true`). The protocol: `<vault> call
3// <operation>`, the input as JSON on stdin; on stdout one JSON object, `{"output": ...}` or
4// `{"error": {"id": "<declared error>", "variables": {...}}}`, with exit status 0 for both. Anything
5// else is a failure of the vault itself. The caller turns the reply into text from its catalog.
6import type { Io } from "./io";
7import type { CallSignal } from "./operations";
8
9export type VaultReply =
10 | { readonly kind: "output"; readonly output: unknown }
11 | { readonly kind: "error"; readonly id: string; readonly variables: Readonly<Record<string, string | number>> }
12 /** The vault didn't answer by the protocol; `detail` is for logs, not the model. */
13 | { readonly kind: "failed"; readonly detail: string };
14
15const TIMEOUT_MS = 60_000;
16
17function variables(value: unknown): Readonly<Record<string, string | number>> {
18 if (typeof value !== "object" || value === null) return {};
19 const out: Record<string, string | number> = {};
20 for (const [k, v] of Object.entries(value)) if (typeof v === "string" || typeof v === "number") out[k] = v;
21 return out;
22}
23
24/** The vault's reply to `stdout`, a run that exited 0. */
25export function parseReply(stdout: string): VaultReply {
26 let parsed: unknown;
27 try {
28 parsed = JSON.parse(stdout);
29 } catch {
30 return { kind: "failed", detail: `the vault's answer isn't JSON: ${stdout.slice(0, 200)}` };
31 }
32 if (typeof parsed !== "object" || parsed === null)
33 return { kind: "failed", detail: "the vault's answer isn't an object" };
34 if ("output" in parsed) return { kind: "output", output: parsed.output };
35 const error: unknown = "error" in parsed ? parsed.error : undefined;
36 if (typeof error === "object" && error !== null && "id" in error && typeof error.id === "string") {
37 return { kind: "error", id: error.id, variables: variables("variables" in error ? error.variables : undefined) };
38 }
39 return { kind: "failed", detail: "the vault's answer has neither output nor error" };
40}
41
42/**
43 * Where the plugin at `plugin` keeps its vault `program`: native/<program>, a starter that runs this
44 * system's build, or native/<program>.exe on Windows (docs/manifests.md, "Native programs").
45 */
46export function vaultPath(plugin: string, program: string, windows: boolean): string {
47 return windows ? `${plugin}\\native\\${program}.exe` : `${plugin}/native/${program}`;
48}
49
50/** One call of a vault: its operation, the input, and the signal of the call it serves. */
51export interface VaultCall {
52 readonly operation: string;
53 readonly input: unknown;
54 readonly signal?: CallSignal;
55}
56
57export async function callVault(io: Io, vault: string, call: VaultCall): Promise<VaultReply> {
58 const run = await io.run([vault, "call", call.operation], {
59 stdin: JSON.stringify(call.input),
60 timeoutMs: TIMEOUT_MS,
61 ...(call.signal === undefined ? {} : { signal: call.signal }),
62 });
63 if (run.exitCode !== 0)
64 return { kind: "failed", detail: `the vault exited ${run.exitCode}: ${run.stderr.slice(0, 500)}` };
65 return parseReply(run.stdout);
66}
67../core/handlers.ts 71 lines1// The Kagi tools' handlers, the same in every shell: each sends its operation to the vault, which
2// holds the session and talks to Kagi (cmd/kagi-vault), and turns the reply into what the model
3// reads. `format` is the one argument the vault never sees: it picks the answer's form here.
4import type { Io } from "../kit/core/io";
5import type { CallSignal, Handlers, Outcome } from "../kit/core/operations";
6import type { VaultReply } from "../kit/core/vault";
7import { callVault } from "../kit/core/vault";
8import { fieldsOf, isFields } from "./data";
9import { errorText, kagiDown } from "./errors";
10import { formatters } from "./format";
11import { model } from "./model.gen";
12import { operations } from "./operations.gen";
13
14const FORMATS = ["markdown", "json"];
15
16/** Where the vault is and how to run it. */
17interface Vault {
18 readonly io: Io;
19 readonly path: string;
20}
21
22async function ask(vault: Vault, operation: string, input: unknown, signal?: CallSignal): Promise<VaultReply> {
23 try {
24 return await callVault(vault.io, vault.path, { operation, input, ...(signal ? { signal } : {}) });
25 } catch (error) {
26 return { kind: "failed", detail: error instanceof Error ? error.message : String(error) };
27 }
28}
29
30function outcomeOf(operation: string, reply: VaultReply, json: boolean): Outcome {
31 if (reply.kind === "error") return { ok: false, error: reply.id, text: errorText(reply.id, reply.variables) };
32 if (reply.kind === "failed")
33 return { ok: false, error: "vault", text: model.text("error-vault", { detail: reply.detail }) };
34 const formatter = formatters[operation];
35 const text =
36 json || formatter === undefined
37 ? model.text("json", { json: JSON.stringify(reply.output) })
38 : formatter(fieldsOf(reply.output));
39 return { ok: true, text, data: reply.output };
40}
41
42async function perform(vault: Vault, operation: string, input: unknown, signal?: CallSignal): Promise<Outcome> {
43 const { format, ...rest } = fieldsOf(input);
44 if (format !== undefined && (typeof format !== "string" || !FORMATS.includes(format))) {
45 const got = typeof format === "string" ? format : JSON.stringify(format);
46 const variables = { name: "format", values: FORMATS.join(", "), got };
47 return { ok: false, error: "one-of", text: errorText("one-of", variables) };
48 }
49 const reply = await ask(vault, operation, isFields(input) ? rest : input, signal);
50 return outcomeOf(operation, reply, format === "json");
51}
52
53/**
54 * A handler per Kagi tool, each calling the vault at `vault` through `io`, stopped when its call is
55 * cancelled; `failed` hears of each outcome saying Kagi isn't working (kagiDown), for the "search"
56 * setting.
57 */
58export function handlersFor(io: Io, vault: string, failed: () => void = () => undefined): Handlers {
59 return Object.fromEntries(
60 operations.tools.map(op => [
61 op.id,
62 async (input: unknown, signal?: CallSignal) => {
63 const outcome = await perform({ io, path: vault }, op.id, input, signal);
64 // A call cancelled midway stopped the vault; that says nothing about Kagi.
65 if (kagiDown(outcome) && signal?.aborted !== true) failed();
66 return outcome;
67 },
68 ]),
69 );
70}
71../core/model.gen.ts 256 lines1// Generated from model.ftl by `bun run build`; edit model.ftl.
2import { Catalog } from "../kit/core/model";
3
4const SOURCE =
5 "## The Kagi tools: what each does, and what each argument is for.\n\nformat-argument = markdown (the default) to read; json for the data as fields, to filter it or pass it on\nlang-argument = Language of Kagi News, e.g. en, de, ja (default en)\n\nsearch = Search the web with Kagi. Returns markdown: each result's title linked to its page, its date and a snippet.\n .query = What to search for\n .time = Only results from this last period\n .from_date = Only results from this date on, YYYY-MM-DD\n .to_date = Only results up to this date, YYYY-MM-DD\n .limit = Results to return (default 10)\n .format = { format-argument }\nbatch-search = Run several Kagi web searches at once (up to 10 queries), with the same filters; one result list per query.\n .queries = The searches\n .time = Only results from this last period\n .from_date = Only results from this date on, YYYY-MM-DD\n .to_date = Only results up to this date, YYYY-MM-DD\n .limit = Results to return per search (default 10)\n .format = { format-argument }\nquick = Ask Kagi's Quick Answer: a short answer written from live search results, with its sources as footnotes.\n .query = The question\n .format = { format-argument }\nstatus = Whether a Kagi session is kept on this computer, where, and whether Kagi accepts it.\n .format = { format-argument }\nnews-search = Search the News tab of kagi.com: articles with their source and age.\n .query = What to search for\n .freshness = Only articles from this last period\n .limit = Results to return (default 10)\n .format = { format-argument }\nnews = Today's Kagi News stories in a category, each summarized with its sources.\n .category = A category's name or id, such as World, Technology, Science, Business or Sports; kagi_news_categories lists all of today's (default world)\n .lang = { lang-argument }\n .limit = Stories to return (default 10)\n .hide = Stories to leave out: preset ids from kagi_news_filter_presets, or keywords\n .format = { format-argument }\nnews-categories = List today's Kagi News categories: the id kagi_news takes, the name and the story count.\n .lang = { lang-argument }\n .format = { format-argument }\nnews-chaos = Kagi News' chaos index today: how unsettled world news looks, 0 to 100, with Kagi's reasons.\n .lang = { lang-argument }\n .format = { format-argument }\nnews-filter-presets = List Kagi News' content-filter presets, which kagi_news can hide, with their keywords.\n .lang = Language of the keywords, e.g. en, de, ja (default en)\n .format = { format-argument }\nsmallweb = The newest posts of Kagi's Small Web feed: personal sites and blogs.\n .limit = Posts to return (default 10)\n .format = { format-argument }\nraw-read = Raw mode, for what the other tools don't cover: GET any page or JSON endpoint of kagi.com (signed in) or news.kagi.com and get the response as it came: status, type, body. For anything but a plain GET, use kagi_raw_write.\n .request = A path on kagi.com (`/html/search?q=x&l=lens`) or an https URL on kagi.com or news.kagi.com\n .format = { format-argument }\nraw-write = Raw mode for requests that may change the person's Kagi account (settings, lenses, Assistant threads) or that need another method than GET; the response comes as it came: status, type, body. Header lines look like `Content-Type: application/json`; without a Content-Type, a JSON body is sent as JSON and any other as a form. Plain reads go through kagi_raw_read.\n .request = The whole request in HTTP's form: `POST /path` (or a full URL on kagi.com or news.kagi.com), optional header lines, a blank line, then the body\n .format = { format-argument }\n\n## What the model is told about searching, by the \"search\" setting (core/settings.json).\n\ninstructions = { $search ->\n [only] Search the web only with the Kagi tools (kagi_search, kagi_batch_search and the other kagi_ tools): never with a built-in web search, another search engine or another search tool. When they fail, use the kagi skill's command-line fallback; when that fails too, say so. Fetch the pages a search finds with your usual fetch or read tool.\n [first] Search the web with the Kagi tools (kagi_search, kagi_batch_search and the other kagi_ tools) first. A built-in web search or another search tool is for when the Kagi tools and the kagi skill's command-line fallback fail, or when the person asks for one. Fetch the pages a search finds with your usual fetch or read tool.\n *[offered] The Kagi tools (kagi_search, kagi_batch_search and the other kagi_ tools) search the web through Kagi, with the person's own Kagi account.\n }\n# Codex's answer to a web.run search it refuses.\nrefused-web-run = { $search ->\n [only] Searching with web.run is off: this person searches the web only with Kagi.\n *[first] Searching with web.run is off while the Kagi tools work.\n } Search with the `kagi_search` tool, or `kagi_batch_search` for several queries at once; the kagi skill has the rest. web.run still opens pages and runs its other commands.\n# Claude Code's answer to a WebSearch call it refuses.\nrefused-web-search = { $search ->\n [only] WebSearch is off: this person searches the web only with Kagi.\n *[first] WebSearch is off while the Kagi tools work.\n } Search with `kagi_search` (`mcp__piebird-kagi-search__kagi_search`), or `kagi_batch_search` for several queries at once; the kagi skill has the rest.\n\n## Answers: a list of results, stories or posts; one item per line, details after \" \u00b7 \".\n\nresult = { $n }. [{ $title }]({ $url })\nwith-detail = { $line } \u00b7 { $detail }\nresult-snippet = {\" \"}{ $snippet }\nresults-none = No results.\nbatch-query = ## { $query }\nanswer = { $answer }\nanswer-references = { $references }\nstory-heading = ### { $heading }\nstory-summary = { $summary }\nstory-sources = Sources: { $sources }\nstory-source = [{ $name }]({ $url })\nstories-none = No stories.\ncategory = - `{ $id }`: { $name } ({ $stories ->\n [one] { $stories } story\n *[other] { $stories } stories\n })\nchaos = Chaos index: { $index }/100 (updated { $updated })\nchaos-description = { $description }\npreset = - `{ $id }` ({ $label }): { $keywords }\nposts-none = No posts.\njoin-comma = { $a }, { $b }\n# The data as JSON, for format json.\njson = { $json }\n\n## Raw mode: Kagi's response as it came.\n\nresponse-head = HTTP { $status } { $type }\nresponse-location = { $head } \u2192 { $location }\nresponse-truncated = { $head } (the first { $mb } MB of the body)\nresponse-body = { $body }\nresponse-saved = The body is { $kb } KB, too long to send whole, so it is saved in { $path } ({ $type }). Read it a part at a time with your file tools (grep, jq for JSON, a range of lines).\nresponse-preview = It starts:\nfenced =\n { $fence }\n { $text }\n { $fence }\n\n## The session: whether one is kept, where, and whether Kagi accepts it.\n\nstatus-signed-in = Signed in to Kagi: the session works ({ $from }).\nstatus-refused = A Kagi session is kept ({ $from }), but Kagi doesn't accept it: { $problem }\nstatus-none = Not signed in to Kagi. The first search opens a sign-in page in the browser.\nsource = { $kind ->\n [env] the { $env } environment variable\n [keychain] the macOS Keychain\n [credential-manager] Windows Credential Manager\n [secret-service] the keyring (the Secret Service)\n *[file] { $path }, readable only by the person\n }\n\n## What each error means.\n\n# A tool's own code failing (a bug, an unexpected error), and arguments its schema doesn't take.\nerror-crashed = the plugin failed: { $cause }\nerror-arguments = the arguments don't fit this tool: { $problem }\nerror-bad-input = the arguments don't fit this tool: { $cause }\nerror-failed = the plugin's vault failed: { $cause }\nerror-vault = the plugin's vault (its program that keeps the Kagi session) didn't answer: { $detail }\nerror-one-of = { $name } is one of { $values }, not \"{ $got }\"\nerror-empty-query = the query is empty\nerror-limit = limit is 1 to 50\nerror-time-and-dates = give time or from_date/to_date, not both\nerror-date = dates are YYYY-MM-DD, not \"{ $got }\"\nerror-dates-order = from_date { $from } is after to_date { $to }\nerror-queries = give 1 to 10 queries, not { $got }\n# A batch's error names the query it came from.\nerror-in-query = \"{ $query }\": { $error }\nerror-sign-in-needed = Kagi needs a sign-in ({ $why ->\n [signed-out] Kagi no longer accepts the kept session\n *[no-session] no Kagi session is kept on this computer\n }). { $opened ->\n [yes] A page opened in the person's browser: { $page }. Ask them to follow it (copy their Kagi Session Link and paste it there), then call this tool again.\n [no] The browser didn't open ({ $problem }). Ask the person to open { $page } on this computer and follow it, then call this tool again.\n *[no-page] The sign-in page couldn't start ({ $problem }).\n } Without a browser on this computer: `\"{ $vault }\" sign-in --link` in a terminal, then paste the link from https://kagi.com/settings/user_details when it asks.\nerror-env-session-refused = Kagi doesn't accept the session in { $env }, which is set where this agent runs and comes before a kept session, so signing in won't help. Ask the person to put the token from their current Kagi Session Link (https://kagi.com/settings/user_details) in { $env }, or to remove { $env } and sign in.\nerror-secret-store = cannot { $doing ->\n [save] save\n [forget] remove\n *[read] read\n } the Kagi session in { $store ->\n [keychain] the macOS Keychain\n [credential-manager] Windows Credential Manager\n [secret-service] the keyring (the Secret Service)\n [file] the plugin's secrets file\n *[other] the secret store\n }: { $cause }\nerror-signed-out = Kagi no longer accepts the kept session (it signed it out)\nerror-http-status = Kagi answered HTTP { $status } for { $path }\nerror-cannot-reach = cannot reach Kagi: { $cause }\nerror-reading = reading { $what ->\n [results] Kagi's result page\n [news] Kagi News\n [news-page] Kagi's news page\n [smallweb] the Small Web feed\n *[answer] Kagi's answer\n }: { $cause }\nerror-no-quick-answer = Kagi gave no quick answer for this query\nerror-no-batch = Kagi News has no current batch\nerror-no-category = Kagi News has no category \"{ $want }\" today; kagi_news_categories lists the { $count } it has\nerror-raw-read-method = kagi_raw_read only reads (GET); send { $method } through kagi_raw_write\nerror-raw-read-target = give one path on kagi.com (/html/search?q=x) or one https URL on kagi.com or news.kagi.com\nerror-raw-read-sign-out = kagi_raw_read leaves the sign-out page alone: it ends the person's Kagi session\nerror-raw-write-start = start with the method and the target, e.g. \"POST /path\" or \"DELETE https://kagi.com/...\"\nerror-raw-write-header = \"{ $line }\" isn't a header line (Name: value); a blank line goes before the body\nerror-raw-write-cookie = leave out Cookie and Host: the session and the host are set for Kagi\nerror-target = the target is a path on kagi.com (/html/search?q=...) or an https URL on kagi.com or news.kagi.com\nerror-host = only kagi.com and news.kagi.com, not { $host }: the session goes to Kagi alone\nerror-cannot-keep = cannot keep the long body in a file: { $cause }\n";
6
7export type Messages = {
8 answer: {
9 answer: string | number;
10 };
11 "answer-references": {
12 references: string | number;
13 };
14 "batch-query": {
15 query: string | number;
16 };
17 "batch-search": undefined;
18 "batch-search.format": undefined;
19 "batch-search.from_date": undefined;
20 "batch-search.limit": undefined;
21 "batch-search.queries": undefined;
22 "batch-search.time": undefined;
23 "batch-search.to_date": undefined;
24 category: {
25 id: string | number;
26 name: string | number;
27 stories: string | number;
28 };
29 chaos: {
30 index: string | number;
31 updated: string | number;
32 };
33 "chaos-description": {
34 description: string | number;
35 };
36 "error-arguments": {
37 problem: string | number;
38 };
39 "error-bad-input": {
40 cause: string | number;
41 };
42 "error-cannot-keep": {
43 cause: string | number;
44 };
45 "error-cannot-reach": {
46 cause: string | number;
47 };
48 "error-crashed": {
49 cause: string | number;
50 };
51 "error-date": {
52 got: string | number;
53 };
54 "error-dates-order": {
55 from: string | number;
56 to: string | number;
57 };
58 "error-empty-query": undefined;
59 "error-env-session-refused": {
60 env: string | number;
61 };
62 "error-failed": {
63 cause: string | number;
64 };
65 "error-host": {
66 host: string | number;
67 };
68 "error-http-status": {
69 path: string | number;
70 status: string | number;
71 };
72 "error-in-query": {
73 error: string | number;
74 query: string | number;
75 };
76 "error-limit": undefined;
77 "error-no-batch": undefined;
78 "error-no-category": {
79 count: string | number;
80 want: string | number;
81 };
82 "error-no-quick-answer": undefined;
83 "error-one-of": {
84 got: string | number;
85 name: string | number;
86 values: string | number;
87 };
88 "error-queries": {
89 got: string | number;
90 };
91 "error-raw-read-method": {
92 method: string | number;
93 };
94 "error-raw-read-sign-out": undefined;
95 "error-raw-read-target": undefined;
96 "error-raw-write-cookie": undefined;
97 "error-raw-write-header": {
98 line: string | number;
99 };
100 "error-raw-write-start": undefined;
101 "error-reading": {
102 cause: string | number;
103 what: string | number;
104 };
105 "error-secret-store": {
106 cause: string | number;
107 doing: string | number;
108 store: string | number;
109 };
110 "error-sign-in-needed": {
111 opened: string | number;
112 page: string | number;
113 problem: string | number;
114 vault: string | number;
115 why: string | number;
116 };
117 "error-signed-out": undefined;
118 "error-target": undefined;
119 "error-time-and-dates": undefined;
120 "error-vault": {
121 detail: string | number;
122 };
123 fenced: {
124 fence: string | number;
125 text: string | number;
126 };
127 "format-argument": undefined;
128 instructions: {
129 search: string | number;
130 };
131 "join-comma": {
132 a: string | number;
133 b: string | number;
134 };
135 json: {
136 json: string | number;
137 };
138 "lang-argument": undefined;
139 news: undefined;
140 "news-categories": undefined;
141 "news-categories.format": undefined;
142 "news-categories.lang": undefined;
143 "news-chaos": undefined;
144 "news-chaos.format": undefined;
145 "news-chaos.lang": undefined;
146 "news-filter-presets": undefined;
147 "news-filter-presets.format": undefined;
148 "news-filter-presets.lang": undefined;
149 "news-search": undefined;
150 "news-search.format": undefined;
151 "news-search.freshness": undefined;
152 "news-search.limit": undefined;
153 "news-search.query": undefined;
154 "news.category": undefined;
155 "news.format": undefined;
156 "news.hide": undefined;
157 "news.lang": undefined;
158 "news.limit": undefined;
159 "posts-none": undefined;
160 preset: {
161 id: string | number;
162 keywords: string | number;
163 label: string | number;
164 };
165 quick: undefined;
166 "quick.format": undefined;
167 "quick.query": undefined;
168 "raw-read": undefined;
169 "raw-read.format": undefined;
170 "raw-read.request": undefined;
171 "raw-write": undefined;
172 "raw-write.format": undefined;
173 "raw-write.request": undefined;
174 "refused-web-run": {
175 search: string | number;
176 };
177 "refused-web-search": {
178 search: string | number;
179 };
180 "response-body": {
181 body: string | number;
182 };
183 "response-head": {
184 status: string | number;
185 type: string | number;
186 };
187 "response-location": {
188 head: string | number;
189 location: string | number;
190 };
191 "response-preview": undefined;
192 "response-saved": {
193 kb: string | number;
194 path: string | number;
195 type: string | number;
196 };
197 "response-truncated": {
198 head: string | number;
199 mb: string | number;
200 };
201 result: {
202 n: string | number;
203 title: string | number;
204 url: string | number;
205 };
206 "result-snippet": {
207 snippet: string | number;
208 };
209 "results-none": undefined;
210 search: undefined;
211 "search.format": undefined;
212 "search.from_date": undefined;
213 "search.limit": undefined;
214 "search.query": undefined;
215 "search.time": undefined;
216 "search.to_date": undefined;
217 smallweb: undefined;
218 "smallweb.format": undefined;
219 "smallweb.limit": undefined;
220 source: {
221 env: string | number;
222 kind: string | number;
223 path: string | number;
224 };
225 status: undefined;
226 "status-none": undefined;
227 "status-refused": {
228 from: string | number;
229 problem: string | number;
230 };
231 "status-signed-in": {
232 from: string | number;
233 };
234 "status.format": undefined;
235 "stories-none": undefined;
236 "story-heading": {
237 heading: string | number;
238 };
239 "story-source": {
240 name: string | number;
241 url: string | number;
242 };
243 "story-sources": {
244 sources: string | number;
245 };
246 "story-summary": {
247 summary: string | number;
248 };
249 "with-detail": {
250 detail: string | number;
251 line: string | number;
252 };
253};
254
255export const model = new Catalog<Messages>(SOURCE);
256../core/operations.gen.ts 63 lines1// Generated from operations.json by `bun run build`; edit operations.json.
2import type { ModelText } from "../kit/core/model";
3import { parseOperations } from "../kit/core/operations";
4import { model } from "./model.gen";
5
6const SOURCE =
7 '{"operations":{"search":{"input":{"type":"object","properties":{"query":{"type":"string"},"time":{"type":"string","enum":["day","week","month","year"]},"from_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"to_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"required":["query"],"additionalProperties":false},"output":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"snippet":{"type":"string"},"date":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["results"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","empty-query","limit","time-and-dates","date","dates-order","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_search","readOnly":true,"openWorld":true,"alwaysLoad":true}},"batch-search":{"input":{"type":"object","properties":{"queries":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":10},"time":{"type":"string","enum":["day","week","month","year"]},"from_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"to_date":{"type":"string","pattern":"^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"required":["queries"],"additionalProperties":false},"output":{"type":"object","properties":{"batches":{"type":"array","items":{"type":"object","properties":{"query":{"type":"string"},"results":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"snippet":{"type":"string"},"date":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["query","results"],"additionalProperties":false}}},"required":["batches"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","queries","empty-query","limit","time-and-dates","date","dates-order","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_batch_search","readOnly":true,"openWorld":true,"alwaysLoad":true}},"quick":{"input":{"type":"object","properties":{"query":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"required":["query"],"additionalProperties":false},"output":{"type":"object","properties":{"answer":{"type":"string"},"references":{"type":"string"}},"required":["answer"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","empty-query","no-quick-answer","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_quick","readOnly":true,"openWorld":true,"alwaysLoad":true}},"status":{"input":{"type":"object","properties":{"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"signed_in":{"type":"boolean"},"from":{"type":"object","properties":{"kind":{"type":"string","enum":["env","keychain","credential-manager","secret-service","file"]},"env":{"type":"string"},"path":{"type":"string"}},"required":["kind"],"additionalProperties":false},"problem":{"type":"object","properties":{"id":{"type":"string"},"variables":{"type":"object"}},"required":["id","variables"],"additionalProperties":false}},"required":["signed_in"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","secret-store"],"vault":true,"tool":{"name":"kagi_auth_status","readOnly":true}},"news-search":{"input":{"type":"object","properties":{"query":{"type":"string"},"freshness":{"type":"string","enum":["day","week","month"]},"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"required":["query"],"additionalProperties":false},"output":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"source":{"type":"string"},"snippet":{"type":"string"},"age":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["results"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","empty-query","limit","sign-in-needed","env-session-refused","secret-store","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news_search","readOnly":true,"openWorld":true}},"news":{"input":{"type":"object","properties":{"category":{"type":"string"},"lang":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":50},"hide":{"type":"array","items":{"type":"string"}},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"stories":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"emoji":{"type":"string"},"category":{"type":"string"},"short_summary":{"type":"string"},"articles":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"link":{"type":"string"},"domain":{"type":"string"},"date":{"type":"string"}}}}},"required":["title"]}}},"required":["stories"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","limit","no-batch","no-category","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news","readOnly":true,"openWorld":true}},"news-categories":{"input":{"type":"object","properties":{"lang":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"categories":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"categoryId":{"type":"string"},"categoryName":{"type":"string"},"clusterCount":{"type":"integer"}},"required":["id","categoryId","categoryName","clusterCount"],"additionalProperties":false}}},"required":["categories"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","no-batch","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news_categories","readOnly":true,"openWorld":true}},"news-chaos":{"input":{"type":"object","properties":{"lang":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"chaosIndex":{"type":"integer"},"chaosDescription":{"type":"string"},"chaosLastUpdated":{"type":"string"}},"required":["chaosIndex","chaosDescription","chaosLastUpdated"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","no-batch","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_news_chaos","readOnly":true,"openWorld":true}},"news-filter-presets":{"input":{"type":"object","properties":{"lang":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"presets":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"keywords":{"type":"array","items":{"type":"string"}}},"required":["id","label","keywords"],"additionalProperties":false}}},"required":["presets"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of"],"vault":true,"tool":{"name":"kagi_news_filter_presets","readOnly":true}},"smallweb":{"input":{"type":"object","properties":{"limit":{"type":"integer","minimum":1,"maximum":50},"format":{"type":"string","enum":["markdown","json"]}},"additionalProperties":false},"output":{"type":"object","properties":{"posts":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"url":{"type":"string"},"author":{"type":"string"},"published":{"type":"string"}},"required":["title","url"],"additionalProperties":false}}},"required":["posts"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","limit","http-status","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_smallweb","readOnly":true,"openWorld":true}},"raw-read":{"input":{"type":"object","properties":{"request":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"required":["request"],"additionalProperties":false},"output":{"type":"object","properties":{"url":{"type":"string"},"status":{"type":"integer"},"content_type":{"type":"string"},"location":{"type":"string"},"body":{"type":"string"},"truncated":{"type":"boolean"},"saved":{"type":"object","properties":{"path":{"type":"string"},"type":{"type":"string"},"bytes":{"type":"integer"},"preview":{"type":"string"}},"required":["path","type","bytes"],"additionalProperties":false}},"required":["url","status","body"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","raw-read-method","raw-read-target","raw-read-sign-out","target","host","cannot-keep","secret-store","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_raw_read","readOnly":true,"openWorld":true}},"raw-write":{"input":{"type":"object","properties":{"request":{"type":"string"},"format":{"type":"string","enum":["markdown","json"]}},"required":["request"],"additionalProperties":false},"output":{"type":"object","properties":{"url":{"type":"string"},"status":{"type":"integer"},"content_type":{"type":"string"},"location":{"type":"string"},"body":{"type":"string"},"truncated":{"type":"boolean"},"saved":{"type":"object","properties":{"path":{"type":"string"},"type":{"type":"string"},"bytes":{"type":"integer"},"preview":{"type":"string"}},"required":["path","type","bytes"],"additionalProperties":false}},"required":["url","status","body"],"additionalProperties":false},"errors":["bad-input","failed","vault","one-of","raw-write-start","raw-write-header","raw-write-cookie","target","host","cannot-keep","secret-store","cannot-reach","reading"],"vault":true,"tool":{"name":"kagi_raw_write","openWorld":true}}}}';
8
9const text = new Map<string, ModelText>();
10text.set("batch-search", model.text("batch-search"));
11text.set("batch-search.format", model.text("batch-search.format"));
12text.set("batch-search.from_date", model.text("batch-search.from_date"));
13text.set("batch-search.limit", model.text("batch-search.limit"));
14text.set("batch-search.queries", model.text("batch-search.queries"));
15text.set("batch-search.time", model.text("batch-search.time"));
16text.set("batch-search.to_date", model.text("batch-search.to_date"));
17text.set("news", model.text("news"));
18text.set("news-categories", model.text("news-categories"));
19text.set("news-categories.format", model.text("news-categories.format"));
20text.set("news-categories.lang", model.text("news-categories.lang"));
21text.set("news-chaos", model.text("news-chaos"));
22text.set("news-chaos.format", model.text("news-chaos.format"));
23text.set("news-chaos.lang", model.text("news-chaos.lang"));
24text.set("news-filter-presets", model.text("news-filter-presets"));
25text.set("news-filter-presets.format", model.text("news-filter-presets.format"));
26text.set("news-filter-presets.lang", model.text("news-filter-presets.lang"));
27text.set("news-search", model.text("news-search"));
28text.set("news-search.format", model.text("news-search.format"));
29text.set("news-search.freshness", model.text("news-search.freshness"));
30text.set("news-search.limit", model.text("news-search.limit"));
31text.set("news-search.query", model.text("news-search.query"));
32text.set("news.category", model.text("news.category"));
33text.set("news.format", model.text("news.format"));
34text.set("news.hide", model.text("news.hide"));
35text.set("news.lang", model.text("news.lang"));
36text.set("news.limit", model.text("news.limit"));
37text.set("quick", model.text("quick"));
38text.set("quick.format", model.text("quick.format"));
39text.set("quick.query", model.text("quick.query"));
40text.set("raw-read", model.text("raw-read"));
41text.set("raw-read.format", model.text("raw-read.format"));
42text.set("raw-read.request", model.text("raw-read.request"));
43text.set("raw-write", model.text("raw-write"));
44text.set("raw-write.format", model.text("raw-write.format"));
45text.set("raw-write.request", model.text("raw-write.request"));
46text.set("search", model.text("search"));
47text.set("search.format", model.text("search.format"));
48text.set("search.from_date", model.text("search.from_date"));
49text.set("search.limit", model.text("search.limit"));
50text.set("search.query", model.text("search.query"));
51text.set("search.time", model.text("search.time"));
52text.set("search.to_date", model.text("search.to_date"));
53text.set("smallweb", model.text("smallweb"));
54text.set("smallweb.format", model.text("smallweb.format"));
55text.set("smallweb.limit", model.text("smallweb.limit"));
56text.set("status", model.text("status"));
57text.set("status.format", model.text("status.format"));
58
59export const operations = parseOperations(SOURCE, text, {
60 crashed: cause => model.text("error-crashed", { cause }),
61 badArguments: problem => model.text("error-arguments", { problem }),
62});
63../kit/core/operations.ts 173 lines1// A plugin's operations (core/operations.json) joined with their text from core/model.ftl, as its
2// generated core/operations.gen.ts carries them, and the contract between the shells and the code
3// that performs an operation. The build has checked the file and the text (tools/build/operations.ts);
4// this reads them into types and refuses what the code can't use.
5import type { ModelText } from "./model";
6
7export type JsonSchema = Readonly<Record<string, unknown>>;
8
9/** MCP's tool annotations, and whether the tool stays in front of tool search. */
10export interface ToolHints {
11 readonly readOnly?: boolean;
12 readonly destructive?: boolean;
13 readonly idempotent?: boolean;
14 readonly openWorld?: boolean;
15 readonly alwaysLoad?: boolean;
16 /** It runs code the model wrote. */
17 readonly runsCode?: boolean;
18}
19
20export interface Tool {
21 readonly name: string;
22 readonly description: ModelText;
23 /** The operation's input with each argument's description, from model.ftl, filled in. */
24 readonly inputSchema: JsonSchema;
25 readonly outputSchema: JsonSchema;
26 readonly hints: ToolHints;
27}
28
29export interface Operation {
30 readonly id: string;
31 readonly input: JsonSchema;
32 readonly output: JsonSchema;
33 readonly errors: readonly string[];
34 /** The plugin's vault performs it: it needs a secret. */
35 readonly vault: boolean;
36 readonly tool?: Tool;
37}
38
39/** What an operation ends with: its answer, as the model reads it and as data, or a declared error. */
40export type Outcome =
41 | { readonly ok: true; readonly text: ModelText; readonly data: unknown }
42 | { readonly ok: false; readonly error: string; readonly text: ModelText };
43
44/**
45 * A call's cancellation as core sees it: the platform's AbortSignal, which aborts when the caller
46 * cancels the call. Core names only what it reads, so it typechecks where there's no AbortSignal
47 * type (a Claude Code mod); a shell that hands it to its platform checks it is one.
48 */
49export interface CallSignal {
50 readonly aborted: boolean;
51}
52
53/**
54 * Performs one operation; the input is as the caller sent it, for the handler to check. `signal`
55 * aborts when the caller cancels the call: whatever the handler started (a program, a wait) stops.
56 */
57export type Handler = (input: unknown, signal?: CallSignal) => Promise<Outcome>;
58
59export type Handlers = Readonly<Record<string, Handler>>;
60
61/**
62 * What the model reads when a tool can't answer as its operation says, from the plugin's catalog:
63 * `crashed`, error-crashed, when its code fails instead (a bug, an unexpected error), with the
64 * failure's own message; `badArguments`, error-arguments, when the arguments don't fit the tool's
65 * input schema, with what doesn't. A plugin with tools has both (tools/build/operations.ts).
66 */
67export interface ToolWords {
68 readonly crashed: (cause: string) => ModelText;
69 readonly badArguments: (problem: string) => ModelText;
70}
71
72export interface Operations extends ToolWords {
73 readonly byId: ReadonlyMap<string, Operation>;
74 /** The operations the model gets as tools, in declaration order. */
75 readonly tools: readonly (Operation & { readonly tool: Tool })[];
76}
77
78/** The failure `error` as the model reads it: the catalog's error-crashed with its message. */
79export function crashText(operations: Operations, error: unknown): ModelText {
80 return operations.crashed(error instanceof Error ? error.message : String(error));
81}
82
83/** For a plugin without tools: it never needs these words. */
84const NO_TOOLS: ToolWords = {
85 crashed: () => {
86 throw new Error("this plugin has no tools");
87 },
88 badArguments: () => {
89 throw new Error("this plugin has no tools");
90 },
91};
92
93type Fields = Readonly<Record<string, unknown>>;
94
95function isFields(value: unknown): value is Fields {
96 return typeof value === "object" && value !== null && !Array.isArray(value);
97}
98
99function fields(value: unknown, at: string): Fields {
100 if (isFields(value)) return value;
101 throw new Error(`operations.json: ${at} isn't an object`);
102}
103
104function textFor(text: ReadonlyMap<string, ModelText>, id: string): ModelText {
105 const found = text.get(id);
106 if (found === undefined) throw new Error(`model.ftl: no ${id}`);
107 return found;
108}
109
110function hints(tool: Fields): ToolHints {
111 const flag = (k: string) => (typeof tool[k] === "boolean" ? { [k]: tool[k] } : {});
112 return {
113 ...flag("readOnly"),
114 ...flag("destructive"),
115 ...flag("idempotent"),
116 ...flag("openWorld"),
117 ...flag("alwaysLoad"),
118 ...flag("runsCode"),
119 };
120}
121
122function toolFor(id: string, op: Fields, text: ReadonlyMap<string, ModelText>): Tool | undefined {
123 if (op.tool === undefined) return undefined;
124 const tool = fields(op.tool, `/operations/${id}/tool`);
125 if (typeof tool.name !== "string") throw new Error(`operations.json: /operations/${id}/tool/name isn't text`);
126 const input = fields(op.input, `/operations/${id}/input`);
127 const props = fields(input.properties ?? {}, `/operations/${id}/input/properties`);
128 const described = Object.fromEntries(
129 Object.entries(props).map(([arg, schema]) => [
130 arg,
131 {
132 ...fields(schema, `/operations/${id}/input/properties/${arg}`),
133 description: textFor(text, `${id}.${arg}`).text,
134 },
135 ]),
136 );
137 return {
138 name: tool.name,
139 description: textFor(text, id),
140 inputSchema: { ...input, properties: described },
141 outputSchema: fields(op.output, `/operations/${id}/output`),
142 hints: hints(tool),
143 };
144}
145
146/**
147 * The operations in `source` (operations.json without `$schema`), each tool given its text, with
148 * `words` for a tool that can't answer; a plugin without tools needs none.
149 */
150export function parseOperations(
151 source: string,
152 text: ReadonlyMap<string, ModelText>,
153 words: ToolWords = NO_TOOLS,
154): Operations {
155 const parsed: unknown = JSON.parse(source);
156 const byId = new Map<string, Operation>();
157 for (const [id, value] of Object.entries(fields(fields(parsed, "").operations, "/operations"))) {
158 const op = fields(value, `/operations/${id}`);
159 const errors = Array.isArray(op.errors) ? op.errors.filter((e): e is string => typeof e === "string") : [];
160 const tool = toolFor(id, op, text);
161 const base = {
162 id,
163 input: fields(op.input, `/operations/${id}/input`),
164 output: fields(op.output, `/operations/${id}/output`),
165 errors,
166 vault: op.vault === true,
167 };
168 byId.set(id, tool === undefined ? base : { ...base, tool });
169 }
170 const tools = [...byId.values()].flatMap(op => (op.tool === undefined ? [] : [{ ...op, tool: op.tool }]));
171 return { byId, tools, ...words };
172}
173../kit/core/model.ts 100 lines1// Everything a plugin sends a model comes from its Fluent catalog: only a Catalog can make
2// ModelText. Strict where Fluent would print a placeholder, and pure, so it runs in a Claude Code
3// mod too.
4import { FluentBundle, FluentNumber, FluentResource } from "./vendor/fluent/index.js";
5
6const MINT: unique symbol = Symbol("model text");
7
8/**
9 * Text for the model. Its brand keeps any other object with a `text` from passing as one, while
10 * every plugin's copy of the kit, and the tools that read them all, agree on the type; the build
11 * refuses the brand written anywhere but here (tools/build/purity.ts).
12 */
13export class ModelText {
14 readonly text: string;
15 readonly brand = "piebird-catalog";
16 constructor(mint: typeof MINT, text: string) {
17 if (mint !== MINT) throw new Error("ModelText comes from a Catalog");
18 this.text = text;
19 }
20 toString(): string {
21 return this.text;
22 }
23}
24
25/**
26 * Texts from the catalog one after another, each on its own line, or with an empty line between
27 * them as paragraphs. Line breaks are the one thing code adds: words come from the catalog.
28 */
29export function joinText(parts: readonly ModelText[], between: "line" | "paragraph" = "line"): ModelText {
30 return new ModelText(MINT, parts.map(p => p.text).join(between === "line" ? "\n" : "\n\n"));
31}
32
33/** The message ids formatted while a trace runs, for the "what the model sees" page. */
34let traced: string[] | undefined;
35
36/** Runs `run` and returns, with its value, the ids of the messages it formatted, in order, each once. */
37export async function traceMessages<T>(run: () => Promise<T>): Promise<{ value: T; ids: string[] }> {
38 const outer = traced;
39 traced = [];
40 try {
41 const value = await run();
42 return { value, ids: [...new Set(traced)] };
43 } finally {
44 traced = outer;
45 }
46}
47
48export type Variables = Readonly<Record<string, string | number>>;
49
50/** Numbers as written, not grouped the locale's way: a line number reads as a call takes it. */
51function plain(variables: Variables | undefined): Record<string, string | FluentNumber> | undefined {
52 if (variables === undefined) return undefined;
53 return Object.fromEntries(
54 Object.entries(variables).map(([k, v]) => [
55 k,
56 typeof v === "number" ? new FluentNumber(v, { useGrouping: false }) : v,
57 ]),
58 );
59}
60
61type Args<M, K extends keyof M> = M[K] extends undefined ? [] : [variables: M[K]];
62
63/** `M` maps each message id, and `id.attribute`, to its variables, or `undefined` when it takes none. */
64export class Catalog<M extends Record<string, Variables | undefined>> {
65 readonly #bundle: FluentBundle;
66
67 constructor(source: string) {
68 // Bidi isolation marks around placeables would reach the model as text.
69 this.#bundle = new FluentBundle("en-US", { useIsolating: false });
70 // The parser skips an entry it can't read; the build refuses those (tools/build/model.ts).
71 const errors = this.#bundle.addResource(new FluentResource(source));
72 if (errors.length > 0) throw new Error(`the catalog: ${errors.map(e => e.message).join("; ")}`);
73 }
74
75 text<K extends keyof M & string>(id: K, ...args: Args<M, K>): ModelText {
76 return this.#format(id, args[0]);
77 }
78
79 /**
80 * The message `id` chosen at run time, such as a vault's declared error. It throws, as `text`
81 * does, when the catalog lacks the message or `variables` lacks one it needs.
82 */
83 textById(id: string, variables?: Variables): ModelText {
84 return this.#format(id, variables);
85 }
86
87 #format(id: string, variables: Variables | undefined): ModelText {
88 const [name, attribute] = id.split(".", 2);
89 const message = this.#bundle.getMessage(name);
90 if (!message) throw new Error(`no message ${name} in the catalog`);
91 const pattern = attribute === undefined ? message.value : message.attributes[attribute];
92 if (!pattern) throw new Error(`message ${id} has no ${attribute === undefined ? "value" : "such attribute"}`);
93 const errors: Error[] = [];
94 const text = this.#bundle.formatPattern(pattern, plain(variables), errors);
95 if (errors.length > 0) throw new Error(`message ${id}: ${errors.map(e => e.message).join("; ")}`);
96 traced?.push(id);
97 return new ModelText(MINT, text);
98 }
99}
100