Saltar al contenido principal
Versión: 0.23.0

Custom hosts

The kit's core never writes to the terminal, so the same agent can run inside a desktop app, a server or a web page. The chat's own logic (opening the session, the person's lines and commands, the turns, /resume, /extensions, the mode, the checkpoints, the session log and the history) lives in a chat controller without an interface: the terminal chats are views of it, and a host of your own is one more.

The chat controller​

createChatController() takes what runChatInk() takes, the options or a session opener, and settings much like the chat's options:

import { buildSessionOptions, createChatController } from "@falkenslab/agent-kit";

const chat = await createChatController((run) => buildSessionOptions(config, run.dir, spec, { run }), {
runsDir, // each run in its folder, resumable
historyPath: path.join(runsDir, "history.jsonl"),
panels: "state", // checkpoints become part of the state (see below)
});

chat.subscribe((state) => window.webContents.send("chat:state", state)); // plain data, every change
await chat.start(); // draws the run's earlier conversation, sends the initial prompt
await chat.send("Tell me a joke"); // a line: a command, or a turn (resolves when it ends)

Its state (getState(), and every change through subscribe()) is plain data, the same for any view, safe to send over IPC or HTTP:

FieldWhat it holds
transcriptThe conversation in blocks: the person's lines (user), the agent's markdown (agent), groups of tool calls with their labels, results and subagents' actions (tools), notices (notice, with a tone: plain, dim, warn, error) and how long each turn took (turn-summary).
busy, turnStartedAt, activity, subagentActivityA turn running, since when, and what the agent and a subagent are doing now.
todosThe agent's task list, while a task is open.
mode, switchableModesThe mode, and the ones the person can switch to.
turns, usage, contextPercentTurns so far, tokens used, how full the context window is.
suggestionThe model's predicted next prompt.
commands, commandDetails, historyThe slash commands the person can type (the session's skills and commands, the extensions' among them, and the chat's own), the same with what each does and its arguments ({ name, description, argumentHint }, for a menu that opens on /), and the person's earlier lines.
languageThe kit's language now: it changes with setLanguage().
panelA checkpoint waiting for an answer, with panels: "state": an approval (decision), a confirmation after a manual step (manual), free text (text) or a choice (choice, with its options).
choiceA choice in place of the prompt (the conversations to resume), without pick.
run, extensionsThe run in use, and the extensions: what runs (with each one's description and capabilities, about), what's off and why, what's installed.

Its actions:

ActionWhat it does
send(line)A line the person sent: an exit command (resolves to "exit"), one of the chat's commands (/resume, /plan, /extensions), an unknown slash command (a warning, never sent), or a turn.
interrupt()Stops the turn running.
setMode(mode), cycleMode(), togglePlan()Switches the mode as the session allows.
listRuns(), resume(dir?)The runs to resume, and switching to one (without dir, it asks: pick, or the state's choice).
newConversation()A new conversation in a new run, the current one left to resume: a graphical view's "new chat" button.
setExtension(name, enabled)Enables or disables an installed extension: in the running session when it was running when the session opened, by opening the session again with the same conversation otherwise. An action, with a notice, not a line of the person's; the state's extensions and commandDetails follow.
extensionCatalog(words?)The catalog: what the agent's marketplaces offer (its officialMarketplace among them), only its catalogTopics', matching words, each with its version, whether it's installed, whether it has an update, its topics, logo and banner.
extensionImage(spec, kind)A catalog entry's logo or banner, as a file for your page to serve (undefined when it has none), from the catalog last listed.
installExtension(spec, { scope?, confirmed? })Installs one from the catalog (<name>@<marketplace>) into the agent's scope, or scope. One from a marketplace that isn't the agent's own isn't installed until confirmed: the result's confirm says which marketplace, for the view to ask the person. The running session is untouched: it's there in a new conversation, or with reopen().
updateExtension(name?)Updates one installed from a marketplace, or all that have a newer version there, keeping their data and options; the names updated.
removeExtension(name, { deleteData? })Uninstalls one, its data too with deleteData: turned off in the running session first when it can be, the session opened again when not.
extensionOptions(name), setExtensionOptions(name, values)An installed extension's options (a secret's value never given), and setting them from a form, secrets too (a form isn't kept in the history, as a typed line is); undefined clears one. Its servers restart with them in the running session, or the session opens again.
reopen()Opens the session again with what's installed now, keeping the conversation: "use it now" after installing.
setLanguage(language, { note? })Switches the language on the fly (switchLanguage()) and opens the session again in it, keeping the conversation: the agent's prompt, its next replies, the labels and the kit's notices. The model is also told with the person's next message, since a conversation in another language pulls the replies towards it; note adds your own words to that (the agent's name in the new language). Build the spec in the opener, not once at startup, if the agent's own texts should follow (see Languages).
api(extension)What an active extension hands the host (its contribution's api), or undefined: the sources extension's addSource(file, name, subfolder?) puts a file the person gives into the sources folder, never overwriting, a duplicate recognized, the person as its origin (a "drop a file here" in the page).
answer(panelId, answer)Answers the state's panel: "y"/"n" for a decision, the text, or the chosen options' numbers ("1,3", and "Other" text on the next lines).
choose(value)Answers the state's choice (null cancels).
notice(text, tone)A notice of the host's (a welcome), in the transcript and the session log.
close()Ends the agent run, the session log and its interaction port.

A view that draws as things happen (a terminal) follows onEvent() instead: the agent's events, the person's lines, notices, the start and end of a turn, an earlier conversation to draw, a mode change, a session opened again.

Checkpoints: with panels: "state" the controller installs its own interaction port, and a checkpoint waits in the state's panel until answer(); the response file still works alongside. With panels: "view" (the default) the host installs its own port, as the terminal chats do.

An Electron main process​

main/agent.ts
import path from "node:path";
import { app, ipcMain, type BrowserWindow } from "electron";
import { buildSessionOptions, createChatController, resolveClaudeAuth, setLanguage } from "@falkenslab/agent-kit";
import { spec, type Config } from "./spec.js";

export async function startAgent(window: BrowserWindow, workspace: string): Promise<void> {
if (!resolveClaudeAuth({ claudeCodeOAuthToken: await readTokenFromKeychain() })) {
window.webContents.send("agent:needs-login");
return;
}
setLanguage(app.getLocale().startsWith("es") ? "es" : "en");

const runsDir = path.join(app.getPath("userData"), "runs");
const config: Config = { mode: "guided", projectDir: workspace }; // the spec's extensions find their folders in it
const chat = await createChatController((run) => buildSessionOptions(config, run.dir, spec, { run }), { runsDir, panels: "state" });

chat.subscribe((state) => window.webContents.send("chat:state", state));
ipcMain.handle("chat:send", (_event, line: string) => chat.send(line));
ipcMain.on("chat:interrupt", () => chat.interrupt());
ipcMain.on("chat:mode", (_event, mode: "guided" | "interactive" | "plan") => chat.setMode(mode));
ipcMain.on("chat:answer", (_event, panelId: number, answer: string) => chat.answer(panelId, answer));
window.on("closed", () => chat.close());
await chat.start();
}

The renderer draws chat:state: the transcript, a spinner while busy, the mode, and a dialog for panel.

Packaging an Electron app​

The agent runs inside the application, packaged by electron-builder into an asar archive. Electron reads files inside it for its own process, but another process can't: the Claude Code CLI, which the SDK starts as a native binary (@anthropic-ai/claude-agent-sdk-<platform>-<arch>, 209 MB on Windows), and which reads the agent's plugins. So the build unpacks them:

package.json
{
"build": {
"asarUnpack": [
"node_modules/@anthropic-ai/claude-agent-sdk-*/**",
"node_modules/@falkenslab/agent-kit/assets/**",
"node_modules/@falkenslab/agent-kit/extensions/**",
"plugin/**"
]
}
}

The last line is the agent's own plugin folder, wherever it is. The kit does the rest: when it runs from inside app.asar, every path it hands to another process (the plugins, the extension launcher) points at app.asar.unpacked, and it gives the SDK the unpacked CLI binary (pathToClaudeCodeExecutable). Without them, a packaged agent fails silently: its first turn hangs, or its skills are missing.

Also:

  • ELECTRON_RUN_AS_NODE: with it in the environment (VS Code's terminal sets it), Electron runs as plain Node and the app fails at its first import { app } from "electron". Unset it when you start the app from such a terminal.
  • Size: about 630 MB unpacked on Windows (Electron about 250 MB, the CLI binary 209 MB).
  • Signing: electron-builder signs every executable it packs, the CLI binary included.
  • Installed extensions: their servers are started with process.execPath, which in Electron is the app itself; the kit sets ELECTRON_RUN_AS_NODE for them, so they run as Node.

Captain Whiskers is a full example: examples/captain-whiskers/web/ (his web host on the controller, with signing in from the page, uploads for request_file and to his treasure chest, a / menu, quick actions and the language switched on the fly) and examples/captain-whiskers/desktop/ (his Electron app and installer).

Things to take care of​

  • Don't import the terminal UI at startup if you don't need it. Everything is exported from the package root; importing it installs the terminal interaction port, which panels: "state" (or your own setInteractionPort()) replaces. The terminal port does nothing without a TTY anyway.
  • One language and one theme per process. Set the language with setLanguage() before building options, or switch it later with the controller's setLanguage(); themes only affect the terminal UI.
  • Credentials: resolveClaudeAuth() never prompts; getting a token (a login screen that runs claude setup-token, a settings field) is your host's job.
  • Where run folders go: somewhere private to the user (app.getPath("userData")), since they hold whole conversations.
  • Your own reader instead: a host can still drive runQuery() and createInputQueue() itself. Then prompt-suggestion arrives after turn-end and mcp-error before the first turn: keep one reader for the whole session.

A server​

The same controller works in an HTTP server: one per conversation, its state streamed to the client (server-sent events, a WebSocket), the person's actions as requests, and panels: "state" for checkpoints. The interaction port is one per process, so one conversation with checkpoints at a time per process. Remember that each session runs a CLI subprocess: limit how many run at once.