App Server
knightcode app-server hosts many conversation threads in one process. The threads share models and credentials but own separate session files, extension runtimes, tools,...
knightcode app-server hosts many conversation threads in one process. The
threads share models and credentials but own separate session files, extension
runtimes, tools, approvals, and turn queues. The desktop app can spawn it; scripts
can use the same protocol.
The transport is newline-delimited JSON over stdin/stdout. Messages use JSON-RPC
2.0 request, response, and notification shapes without a "jsonrpc" field.
There is no protocol version number: the client and the server ship together,
and a client that needs a newer server checks serverInfo.version. It is
separate from the single-session RPC mode.
Starting
knightcode app-server [-e <extension>]... [--session-dir <dir>]-e / --extension is repeatable. Paths resolve against the process working
directory and load in each thread as explicitly trusted CLI paths. Project-local
resources still follow project trust.
Extensions see mode: "desktop" and hasUI: true; their dialogs and the
extension UI that reaches a desktop client are under
Dialogs and extension UI. Threads a terminal-mode
connection loads have hasUI: false.
Session storage uses --session-dir, then
KNIGHTCODE_CODING_AGENT_SESSION_DIR, then the global sessionDir setting.
Without an override, sessions use the per-project layout described in
Sessions. stdout carries only protocol messages; logs go to stderr.
Closing stdin shuts down the server. knightcode app-server --help (or -h)
prints usage on stderr and exits without opening a connection.
Handshake
Send a request with a string or numeric id:
{"id":1,"method":"initialize","params":{"clientInfo":{"name":"example","version":"1.0"},"capabilities":{"optOutNotifications":["item/reasoning/delta"]}}}The result contains serverInfo and platform: {os, arch, homeDir}. os is win32, darwin, or linux.
capabilities.optOutNotifications is an optional array of at most 256 method
names, scoped to this connection. Unknown names are tolerated. It filters only
notifications, never responses or server requests such as approvals.
Wait for its result, then send the notification:
{"method":"initialized","params":{}}Only then send other requests. Requests before initialization return notInitialized. Unknown methods return
methodNotFound; invalid parameters return invalidParams.
Threads and turns
Start a thread with its project directory:
{"id":2,"method":"thread/start","params":{"cwd":"/path/to/project","approvalMode":"ask"}}
{"id":3,"method":"model/list","params":{}}Wait for each response. thread/start and thread/fork return
{thread, warnings, ui} (ui is the thread's extension UI, see
Dialogs and extension UI). A fork is named after its source with (fork) appended. thread/resume also returns warnings; render them from the
response, because they can occur before subscription. A warning has
{message, source, path?, line?, column?}, where source is settings,
extension, trust, or server. Malformed settings, refused extension loads,
and undecided project trust do not silently disappear. Later extension errors
send a subscriber-only warning with threadId.
Threads and list summaries include createdAt (header creation time) and
optional forkedFromId (the parent's header ID, not a UUID inferred from its
filename). A loaded thread also carries sessionFile, the session file's path,
once it has been saved. List summaries use file modification time for
updatedAt; loaded threads use the latest entry time, falling back to the
header. Their status is one of:
{type: "notLoaded"}{type: "idle"}{type: "active", waitingOn: []}; the array can containapprovalanddialog.{type: "systemError", message}; ownership loss remains visible after unload until a successful reload.
thread/status/changed broadcasts these objects, including the first opening
and last closing of an approval or dialog.
Extension providers appear in model/list after a thread has loaded them.
Each model has thinkingLevels, the accepted picker values; non-reasoning
models report ["off"]. Pick a model using thread/settings/update, with
threadId and model: {provider, id}, before starting a turn if no usable model
is selected.
{"id":4,"method":"turn/start","params":{"threadId":"THREAD_ID","text":"Explain this repository","requestId":"client-start-1","clientMessageId":"optimistic-1","images":[{"path":"/absolute/path/screenshot.png"}]}}A start returns {status, threadId, requestId}. status is started, handled
(an extension consumed the input), or cancelled (cancelled before acceptance).
It does not wait for the turn to finish. The optional client token requestId
is echoed in the response and turn/started; it is distinct from the JSON request
id. Session-replacing commands preserve it and return the effective target
thread ID. The first acceptance wins even if cancellation follows later.
turn/start and turn/queue/add accept optional clientMessageId (1–128
characters). It is echoed on the first live userMessage of the turn the input
starts, after input transformations; it is not persisted. A handled or removed
input creates no matching item.
Their images accept inline {mimeType, data} base64 images or {path} on the
server's shared filesystem. Path images must be absolute, regular PNG, JPEG,
GIF, or WebP files of at most 20 MiB; missing, relative, non-image, directory,
and oversized inputs return invalidParams.
A plain text turn sends these notifications in order:
turn/starteditem/startedanditem/completedfor the user messageitem/startedfor the agent message- Zero or more
item/agentMessage/deltanotifications item/completedfor the agent messageturn/completed
A turn is one agent run between durable start and end entries. Automatic retries,
recovery, and steering may span several low-level agent runs inside it. Turn IDs come from persisted marker entries; item IDs use durable
ordinals except tool calls, which retain their call IDs. Final messages decide
item contents and statuses. Turn requires numeric startedAt and optionally
has completedAt; items optionally carry both. Times are Unix milliseconds,
unlike thread creation/modification times, which are ISO strings. Durable turn
markers and end entries determine final turn times. Assistant text/reasoning
uses message timestamp and duration, and tool calls finish at the tool-result
timestamp. User, compaction, and extension items may have only completion time.
Nested calls are live-only. An interrupted turn without an end entry has no
completion time.
Started-item times and streaming contents are provisional. item/completed
replaces times, text, fileChanges, and index; do not just append deltas.
index is the item's top-level position in the turn: order by its latest value,
because a streaming item's index can change at message end. item/removed
withdraws an item dropped by a message-end handler.
Nested tool calls carry parentItemId and no top-level index. A saved turn with
no end entry reads as interrupted. See Session Format.
Completed edit/write calls include fileChanges: [{path, kind, diff?}];
kind is add, modify, or delete. A non-empty tool-result patch becomes
diff. Extension tools can report validated file changes through result details;
a malformed report is ignored as a whole. Failed calls report no changes.
turn/completed contains a summary without items, avoiding a second large
transcript transfer. Failed turns have error: {message, kind}, where kind
is auth, rateLimit, contextOverflow, network, provider, or other,
classified from the error text. Summaries and references bound error.message to 2,000 characters;
full payloads retain it. Interrupted outcomes take precedence over failures.
turn/steer hands input to the running turn and requires expectedTurnId, the
currently running turn ID. A stale ID returns turnMismatch, so a steer never
lands in a later turn. A steer cannot be taken back once accepted.
turn/queue/add queues input on the server and returns {queueId}. Each queued
input starts a turn of its own once the thread is idle, oldest first; on an idle
thread it starts at once. Until it starts it is removable with
turn/queue/remove. A queued input that fails to start (no model, signed out) is
dropped with an error notification carrying its clientMessageId, if it had
one. turn/queue/updated sends
{threadId, steering: [text], followUp: [{id, text, attachments?}]}: steers the
agent has not delivered yet, and the queued inputs. text is the user's own (not
a prompt composed with review comments); attachments counts the images and
review comments an input carries that its text alone does not.
turn/interrupt accepts optional turnId; a supplied stale ID returns
turnMismatch, while omitting it retains interrupt-current behavior. It drops
queued inputs, as Escape does in the terminal; the client still has their text
from turn/queue/updated to restore into its composer.
An interrupt answers at once; it does not wait for extension hooks. A start
interrupted during its input hooks or an extension command returns cancelled
and launches nothing, but hooks already running finish. A start interrupted
later, during compaction before the run or before_agent_start, returns
started, and its turn completes interrupted without a model request.
Settings changes and compaction serialize with starts;
a thread already running refuses another start with turnInProgress. Settlement
hooks and their deferred dispatch also retain admission until they finish.
Steering input still waiting in interception belongs to that running
turn: interruption, unloading, or settlement cancels it. It cannot silently enter
a later turn; the waiting request returns invalidParams.
Subscriptions and replacement
thread/start, thread/resume, thread/fork, and turn/start subscribe the
requesting connection. thread/unsubscribe withdraws it. Every initialized
connection gets thread/sidebar and account updates, including
thread/turn/completed: {turn} for completed, failed, and interrupted outcomes.
Only subscribers get turn/completed, turn/item notifications, deltas, retries,
queue updates, and thread/tokenUsage/updated. Approvals target one subscriber
at a time.
Initialize visible sidebar rows from newest-page thread/read, without loading
or subscribing to all threads or resolving referenced turn payloads. Keep live
outcomes arriving during a read and apply them after its result. An absent saved
last-seen turn ID establishes a baseline; live completions can still mark unread.
Active/waiting status outranks the previous outcome; systemError stays separate.
Desktop OS notifications are for live outcomes, not initial history.
thread/resume returns history and subscribes in the same synchronous send step.
Its separate runningTurn is a current snapshot, even when an old history cursor
is supplied. Its required queue and usage are also live send-time snapshots,
not values from handler entry or the history cursor; so is ui. Buffer
queue/usage/ui/* events while awaiting the response, install the snapshots (an
empty queue clears stale state), then replay the buffered events. Invalid cursors subscribe nobody.
Install runningTurn before applying buffered deltas. thread/replaced moves
subscriptions to the new thread; captured old extension contexts become stale.
thread/unsubscribe returns {status: "unsubscribed" | "notSubscribed"}.
TokenUsage contains cumulative input, output, cacheRead, cacheWrite,
and cost, plus optional contextTokens and required contextWindow.
thread/tokenUsage/updated: {threadId, turnId, usage} arrives after every finalized
assistant reply, including replies before tool results and compacted-history
billing. contextTokens can be absent when the current context is unknown.
History, listings, and large values
thread/read reads saved history without loading or locking the thread and never
writes the source. thread/resume loads and owns it. History pages contain at most
50 turns, newest page first; turns within a page are chronological. nextCursor
anchors later pages to the original branch prefix, so appends cannot shift the
page. Diverged, foreign, or malformed cursors return invalidCursor.
Initial thread/list uses a per-directory .thread-index.json metadata cache.
It stats transcripts and rescans only new or changed files; the first cold scan
still reads their full history. Cached titles are bounded, never full messages,
and fork origins are cached after resolving parent headers. Warm listings open
no unchanged transcripts or parent headers. Missing, corrupt, invalid, or
oversized (over 16 MiB) indexes rebuild safely; cache writes are best effort.
Directories are scanned sequentially, with at most 64 transcripts open.
Continuations use an immutable summary snapshot without rescanning. Up to 32
snapshots are retained with a five-minute sliding expiry; expired, evicted, or
filter-mismatched cursors return invalidCursor.
Large turns/items and tool input/output use immutable payload references rather
than oversized protocol lines. Call payload/read with the reference ID and a
byte offset; concatenate base64-decoded chunks until eof. nextOffset, when
present, is the next byte offset, not a character count. References expire after
30 minutes without access; reread the thread to obtain new references. IDs grant
access only to server-owned spool files, not arbitrary filesystem paths.
Approvals
Desktop threads default to ask; terminal-mode in-process threads default to
fullAccess. Effective mode is persisted on the branch; invalid stored state
restores as ask.
| Tool | ask | autoEdit | fullAccess |
|---|---|---|---|
read, grep, find, ls, ask_user, submit_plan | Allow | Allow | Allow |
edit, write | Ask | Allow | Allow |
| Shell, other extension, and MCP tools | Ask | Ask | Allow |
Built-in plan and classifier restrictions run before this gate. Approvals are not a filesystem or operating-system sandbox.
The server sends item/toolCall/requestApproval with thread/turn/item IDs, the
tool name, input or an input reference, and required decisions. Optional
reason explains the gate (for example, an outside-workspace write), and
optional prefix is the offered shell command prefix. Render the supplied
choices, not a hardcoded list. Reply to its id with a result such as
{"decision":"accept"} or
{"decision":"decline","message":"Use the project-local file instead"}.
A decline message is optional and bounded to 4,000 characters; it reaches the
agent as the tool's decline reason. cancel always blocks execution. An
unoffered decision is treated as decline and never grants permission.
acceptForSession remembers a non-shell tool name for this thread's load,
never across unloads. It is never offered for bash or PowerShell. Shells
can instead offer acceptPrefixForSession with a positively understood prefix.
For example, a git status grant covers git status --short, not git reset.
Only simple, recognized command forms get a prefix; chaining, redirection,
substitution, quoted/escaped command names, script dispatchers, interpreters,
nested wrappers, and unrecognized subcommand forms fail closed. A later command
must independently produce exactly the granted prefix, not merely start with
its text. Grants are shell-specific and confined to this thread's load.
The most recent initiating subscriber is asked first. Disconnect or unsubscribe
cancels that attempt and migrates it to another subscriber; no subscribers means
cancel. serverRequest/cancelled withdraws an outstanding request.
Dialogs and extension UI
What KnightCode asks the user mid-turn reaches a desktop connection as a server
request, asked the way approvals are; the thread reports waitingOn: ["dialog"]
meanwhile.
item/toolCall/requestUserInput carries ask_user's questions, all at once,
with the itemId of the call asking, so a client can move back and forth
between them before answering. Each question has an id, its text and its
options (label, description); a label ending in (Recommended) is the
recommended one. Reply {"answers": {"<id>": {"label": "..."}}} with the
chosen option's exact label and an optional note, or {"other": "..."} for
the user's own answer; leave out a skipped question. {} dismisses the
questions and stops the turn.
ui/dialog carries the rest, such as the classifier gate's confirmation of a
risky call and plan mode's review. kind says which: a select carries
options, a confirm its message, an input its placeholder, an editor its
prefill. Reply with {"value": "..."} for a select, input or editor,
{"confirmed": true} for a confirm, or {} to cancel.
Notices such as "Downloaded model" arrive as the notification ui/notify with
a level of info, warning or error.
What an extension shows beside the transcript is the thread's extension UI:
- The
uiof thethread/start,thread/resumeandthread/forkresults is all of it as it stands when the response is written:{statuses: [{key, text}], title?, workingMessage?, workingVisible, widgets}. Statuses are sorted by key. Each widget is{key, placement, lines}or{key, placement, view},placementbeingaboveEditororbelowEditor; those above come first, each group in the order they were last set. ui/statecarries every field but the widgets, whole, whenever one changes. An absenttitleorworkingMessageis a cleared one.workingVisible: falseasks the client to hide its waiting indicator, andworkingMessagereplaces its text.ui/widgetcarries one widget set or replaced (it moves to the end of its placement), or{threadId, key}alone when it is removed.
Install ui from the result, then apply the notifications, buffered across a
resume as queue is. A /reload clears everything the old extensions showed,
with one ui/state and a removal per widget, before the new ones start. The
server strips ANSI escapes and keeps text on one line, at most 200 code points;
widget lines keep their spacing. A view is data, never
HTML; the server checked it before sending.
A button or field in a widget's view answers with the request ui/event
(below), which reaches every handler the thread's extensions registered with
ctx.ui.onViewEvent. Views on toolCall and extensionMessage items (their
optional view) are for display only.
Widget factories, custom() components, header and footer components, raw
terminal input and the working indicator draw only in the terminal: the server
writes one line to its log per method per thread load and draws nothing. Editor
access (setEditorText, getEditorText, pasteToEditor) does nothing.
Threads a terminal-mode connection loads have no dialogs or extension UI yet.
Workspaces, diffs and git
A thread runs in a workspace: the user's checkout ({mode: "local"}), or a git
worktree of its own ({mode: "worktree"}), as in the Codex desktop app.
thread/start {cwd, workspace: {mode: "worktree", baseRef?, carryLocalChanges?, environment?}}
creates a worktree on a detached HEAD at baseRef (default:
the checkout's HEAD) under <worktrees.root>/<4 hex digits>/<repository name>
and runs the thread there, in the same subdirectory the cwd had. No branch is
created until git/branch/create or a handoff makes one. carryLocalChanges
brings the checkout's uncommitted changes (untracked files included, ignored
files not) along as uncommitted edits. Creation runs no hook, filter or user
code, so a failed creation leaves nothing behind. A start whose cwd is inside
a managed worktree is a new thread in that worktree.
thread/workspace/progress {threadId, phase, output} streams the output of an
environment's setup or cleanup script to the connection that asked. Every
Thread carries workspace; ThreadSummary.workspaceMode marks worktree
threads in listings.
Diffs come in Codex's Changes scopes: lastTurn, uncommitted (the default),
unstaged, staged, committed and branch. Each hunk has a content-hash id;
an action naming a hunk that changed since it was read answers gitConflict
with data.reason: "diffChanged". Files changed while a turn ran are reported
by turn/diff/updated {threadId, turnId, files} after turn/completed, each
file as {path, additions, deletions}: the net line totals between the working
tree before and after the turn. A read-back turn carries the same list as
Turn.files. This
means "changed while the turn ran", not "changed by the turn": an edit another
local thread or an editor made meanwhile is in the list too. A worktree
thread's list is its own unless another thread runs in the same worktree (a
start whose cwd is inside it), whose edits it can include. Untracked files
count up to 500; past that, those under a folder holding 200 or more untracked
files are left out, and if more than 500 still remain before or after the
turn, only tracked files count.
Review comments are drafts the client keeps. They travel with the next
turn/start or turn/queue/add as comments; the server quotes the commented
lines from the diff the user reviewed, and the user item shows the comments
next to the typed text.
Reverting an added file deletes it, an untracked one included. Clients confirm with the user before a revert that removes files.
thread/handoff moves a chat and its uncommitted changes between its worktree
and the main checkout. The destination must be clean: "Stash or commit your
local changes to hand off." Archiving a thread deletes its worktree after a
snapshot, kept as refs/knightcode/snapshots/<bucket>/<time>, and
thread/worktree/restore rebuilds it. Past worktrees.keep worktrees, the
least recently used are pruned the same way. Outside a git repository, diff and
git methods answer notGitRepo.
gitConflict errors carry data.reason: diffChanged, patchFailed,
concurrentChange, pushRejected, localChanges, operationInProgress,
branchExists, recoveryRequired, worktreeDeleted, worktreeMissing,
worktreeInUse or submodules, with paths, output or
recoveryInstructions where they apply.
Method reference
All methods require the handshake. Common errors are notInitialized,
invalidParams, and shuttingDown; the additional errors below are
method-specific. The generated schema remains the exhaustive field reference.
turn/queue/remove
Params: {threadId, queueId}. Result: {removed}. removed: true means the
input will not start; false means it was unknown, already started, or already
removed. This does not edit saved history.
Errors: threadNotFound.
{"id":10,"method":"turn/queue/remove","params":{"threadId":"THREAD_ID","queueId":"QUEUE_ID"}}config/read
Params: optional {cwd}. Result: {settings, defaults, origins, versions, paths, errors}.
Without cwd, only global settings are read. With cwd, trusted project settings
merge over global settings; untrusted project files are not read. origins
maps dotted leaf keys to global or project. versions.global and optional
versions.project are SHA-256 hashes of raw file contents, or "none" for an
absent or unread untrusted scope. defaults is what an unset key means, in the
shape of settings; paths names the global and, with cwd, the project file. Settings, origins, versions, and parse errors
come from the same scope reads, not cached settings paired with fresh versions.
Parse errors appear as structured source: "settings" warnings with an
optional path and available one-based line/column; unaffected scopes still read.
Storage read/lock failures return internal.
{"id":11,"method":"config/read","params":{"cwd":"/path/to/project"}}config/write
Params: {scope, key, value, cwd?, expectedVersion?}. scope is global or
project; key is a dotted key such as retry.provider.maxRetryDelayMs.
value: null deletes the leaf. Result: {version}, after the write and reload.
Project writes require cwd and an explicitly trusted project. Writes and ordinary
settings setters share a per-file queue across manager instances. Under the file
lock, expectedVersion, if present, compares against the current raw-content
hash; existing migrations run before the edit, so deleting a canonical key does
not resurrect its legacy alias. Corrupt JSON is refused without overwriting it.
Errors: configConflict with data: {version} when the file changed; reread and
resolve the conflict rather than blindly retrying. invalidParams rejects
untrusted project writes (data.reason: "untrusted"), missing project cwd,
malformed keys, and prototype-pollution segments (__proto__, constructor,
prototype). Read/parse/write failures return internal.
{"id":12,"method":"config/write","params":{"scope":"global","key":"theme","value":"dark","expectedVersion":"none"}}tool/list
Params: optional {cwd}. Result: {tools: [{name, description, source}], defaultTools}.
The tools a new thread in cwd would register (without cwd, a
project-less one), from an in-memory session like command/list's; hidden tools
are left out, and MCP tools appear only once a thread connects. defaultTools is
the global defaultTools setting resolved (+name and -name applied), or the
built-in default read, bash, edit, write. Change it with config/write.
{"id":26,"method":"tool/list","params":{}}tool/settings/read and tool/settings/write
The KnightCode tools /tools manages, stored in tools.json in the agent
directory. tool/settings/read {} returns
{tools: [{name, enabled, settings}], classifiers: [{provider, id, name}]}: each tool's state (its default when the
file does not set it) and its own settings, with values of keys ending in Key
masked to their last four characters; classifiers are the classifier models
with credentials, for the classifier gate's model.
tool/settings/write {name, key, value} sets one setting; enabled takes a boolean, other keys a string,
and null deletes the key. An unknown tool or a malformed key is
invalidParams.
{"id":27,"method":"tool/settings/write","params":{"name":"websearch","key":"provider","value":"brave"}}mcp/list, mcp/update, mcp/add and mcp/remove
The servers of the global mcp.json and, for an explicitly trusted project, its
.knightcode/mcp.json (MCP). mcp/list {cwd?} returns
{servers, errors, paths}: each server's name, scope, defining file (source), the
project file that overrides it (override), transport (stdio or http),
target (command line or URL), description, enabled and exposure.
mcp/update {cwd?, name, enabled?, exposure?} saves the change where /mcp
would: in the project override when there is one, else in the defining file.
mcp/add {scope, cwd?, name, config} validates config as an mcpServers entry
and adds or replaces it (result {replaced}); auth is refused in a project
file. mcp/remove {scope, cwd?, name} returns {removed}. Project writes need
cwd and a trusted project (invalidParams, data.reason: "untrusted").
Connections, sign-in and status belong to the MCP extension in each thread
(/mcp); loaded threads pick up file changes when they next load.
{"id":28,"method":"mcp/add","params":{"scope":"global","name":"docs","config":{"url":"https://example.com/mcp"}}}mcp/app/readResource and mcp/app/callTool
MCP Apps (MCP): a client that draws apps sends desktop as
its mode; the thread's MCP servers then hear the client supports
io.modelcontextprotocol/ui. A call to a tool with an app carries
mcpApp: {server, resourceUri, result?, resultOmitted?} on its toolCall
item, live and read back: result is the server's whole CallToolResult
(with _meta and structuredContent), or resultOmitted: true past 256 KiB
serialized, when the app gets the item's text output instead.
mcp/app/readResource {threadId, server, uri} returns the server's
ReadResourceResult {contents, _meta?}, the app's HTML first.
mcp/app/callTool {threadId, server, name, arguments?, confirmed?} returns its
CallToolResult {content, structuredContent?, isError?, _meta?}. Both reach
only the named server, through the thread's MCP extension, and only while it is
connected (mcpUnavailable otherwise); a call reaches only a tool whose
_meta.ui.visibility includes "app" and that the configuration does not hide
(invalidParams otherwise). Outside fullAccess, a tool not annotated
readOnlyHint answers approvalRequired (data: {server, tool}) until sent
again with confirmed: true after the user agreed. Arguments over 1 MiB and a
resource over 4 MiB, serialized, answer invalidParams; a tool's result over
4 MiB is left out of an answer whose text says the tool ran, since it did. A call
during a turn runs
at once; the server gives the MCP extension 60 seconds to answer a resource read
(internal after that), and a tool call as long as the server's own timeout.
{"id":29,"method":"mcp/app/callTool","params":{"threadId":"THREAD_ID","server":"counter","name":"increment","arguments":{"by":1},"confirmed":true}}resource/list and resource/set
Extensions, skills, prompt templates and themes as knightcode config lists them.
resource/list {cwd?} returns
{resources: [{type, path, name, enabled, scope, origin, source}]} for what a thread in cwd (or with no project) would find,
installing nothing. Built-in extensions are left out: they are always on, so
resource/set refuses them like any unknown path. resource/set {cwd?, type, path, enabled} loads or
skips one resource with a + or - pattern in the settings of the scope that
defines it: a top-level resource in that scope's extensions, skills,
prompts or themes, a package's in that package's filter. Project resources
need a trusted project; an unknown path is invalidParams.
{"id":29,"method":"resource/set","params":{"type":"skills","path":"/home/u/.knightcode/agent/skills/review/SKILL.md","enabled":false}}plugin/*
Plugins, as knightcode plugin manages them. scope is global (the user's
settings) or project; project writes need a trusted project (invalidParams with
reason: "untrusted" otherwise). name is a plugin's name, name@marketplace, or its identity.
plugin/list {cwd?}returns{plugins, diagnostics, importable}: each plugin's identity, scope, source, commit,enabled,trusted,active(what a new thread incwdloads), skills, commands, MCP servers, the environment variables they read, unsupported components and warnings; andimportable, the plugins Claude Code and Grok Build installed that are not installed here, each{name, from, source, enabled}with thename@marketplacethat installs it.plugin/install {scope, cwd?, source, ref?, path?, trust?}installs without trusting unlesstrust; an untrusted plugin runs nothing.name@marketplacefrom a known marketplace that is not added yet adds it first.plugin/set {scope, cwd?, name, enabled?, trust?},plugin/update {scope, cwd?, name}andplugin/uninstall {scope, cwd?, name}.trust: truetrusts the plugin;plugin/setnever revokes trust (falseleaves it as it is), and uninstalling the plugin from every scope does.plugin/marketplace/list {cwd?}returns{marketplaces}: every marketplace/pluginslists (built in, added, then Claude Code's and Grok Build's), each{name, source, ref?, from?, added, scope?, updatedAt?, missing?, plugins, diagnostics}with its whole catalog, each plugin{name, description?, version?, author?, installed, update}. Reads local state only.plugin/marketplace/update {cwd?, name?}fetches the named marketplace's catalog, added or not, without adding it (offline, only a folder's); withoutname, every added one.plugin/marketplace/add {scope, cwd?, source, ref?}takes a source or a known name, andplugin/marketplace/remove {scope, cwd?, name}keeps the plugins installed from it. Adding a marketplace installs nothing.
Reads use local state only. Changes apply to threads that start, resume, or reload afterwards.
command/list {cwd} includes the commands and skills of the plugins a new thread would load.
{"id":30,"method":"plugin/install","params":{"scope":"global","source":"acme/review-tools"}}project/trust/read
Params: {cwd}. Result: {trusted, needsTrust, project}. trusted is true,
false, or null (undecided); needsTrust means project-local resources require a
decision; project is the path the decision is kept under. This reads the
server-owned trust store, not a separate
desktop-side preference. At a managed worktree's cwd, trusted is its
project's decision, the one the worktree's threads load with and the project
settings, MCP and resource methods check there, and project is that project.
{"id":13,"method":"project/trust/read","params":{"cwd":"/path/to/project"}}project/trust/set
Params: {cwd, trusted} with a boolean decision. Result: {}. Writes the
server-owned trust store; subsequent loads and config reads use it. Already
loaded runtimes are not silently rebuilt. At a managed worktree's cwd, the
decision is recorded for its project. A cwd that is not an existing
directory returns invalidParams. Filesystem failures return internal.
{"id":14,"method":"project/trust/set","params":{"cwd":"/path/to/project","trusted":true}}file/search
Params: {cwd, query, limit?, searchId?}. query is 1–256 characters, limit
is 1–200 (default 50), and searchId is at most 128 characters. Result:
{files: [{path, score, indices}], truncated} with cwd-relative paths, fuzzy
scores, and matched character indices. The server lists paths with fd using
an argument array, never a shell. A newer request using the same searchId
(including from another connection) immediately supersedes the older one,
which returns {files: [], truncated: false} unless shutdown takes precedence.
Listings have a ten-second TTL, at most eight cached cwds, and at most two active
listings. Output is capped at 200,000 paths or 32 MiB stdout, with
truncated: true when the bounded listing is retained; a 15-second timeout
rejects with internal. Partial failed output is usable but never cached;
empty failed output rejects. No remaining waiters means abort; shutdown always
returns shuttingDown, even if child rejection
arrives after superseding. toolMissing means fd is unavailable; other empty
listing failures return internal.
{"id":15,"method":"file/search","params":{"cwd":"/path/to/project","query":"src/config","limit":20,"searchId":"composer"}}command/list
Params: {threadId} or {cwd}. Result: {commands: [{name, description}]}:
extension commands, prompt templates, and skills as skill:<name>, without the
leading /. With cwd, the server lists what a new thread in that project would
offer, from a session it builds in memory and never binds: no extension starts,
and nothing is written. Built-in commands that only work in the terminal
(usage, llama, undo) are left out.
{"id":17,"method":"command/list","params":{"cwd":"/path/to/project"}}ui/event
Params: {threadId, widgetKey, id, value?}. Result: {}. A press of the button
id, or a change of the field id, in the widget widgetKey's view: value is
a string for a text or select field, a boolean for a toggle, and absent for a
button. Every handler of the thread gets {widgetKey, id, value?}; a handler
that fails becomes a warning. The extension answers by setting the widget
again: a field shows the view's value, not what was typed. A press of a widget or
control that is gone answers {} and reaches nobody.
Errors: threadNotFound when the thread is not loaded, and invalidParams when
the value does not fit the control (a toggle's string, a select value outside its
options, a button with a value).
{"id":18,"method":"ui/event","params":{"threadId":"THREAD_ID","widgetKey":"checks","id":"verbose","value":true}}thread/backgroundShells/list, thread/backgroundShells/output and thread/backgroundShells/stop
The commands a thread's shell calls left running in the background (run_in_background, or
a foreground command moved there while it ran, as the terminal's Ctrl+B does), and the last
50 that ended; they end with the session, so a thread that is not loaded has none. list
takes {threadId} and answers {shells: [{id, command, cwd, status, startedAt, endedAt?, exitCode?, error?}]}, oldest first; status is running, completed, failed, stopped
or timed_out, and error says why a command failed without an exit code. After that,
subscribers get thread/backgroundShells/updated: {threadId, shells}, the whole list,
whenever a command starts or ends. A command asked to stop stays running until it exits.
output takes {threadId, id} and answers {output}: the end of the command's output with
its terminal escapes, the last 2,000 lines or 50 KB, kept after the command ended.
stop takes {threadId, id}, stops a running command as the user, and answers {stopped},
false when it had already ended or was already stopping. A command the user stops is
recorded for the model without starting a turn. Both answer invalidParams for an id the
thread does not know.
A shell call whose command went on in the background carries backgroundShellId, and the
extensionMessage that tells the model commands ended (extension background_shell)
carries backgroundShells: [{id, command, status, exitCode?, error?, outputTail}], both
live and read back; a notice's status is how the command ended, never running.
{"id":19,"method":"thread/backgroundShells/stop","params":{"threadId":"THREAD_ID","id":"ab12cd34"}}thread/delete
Params: {threadId}. Result: {}. Permanently removes an archived transcript;
it does not use the interactive resume selector's trash command. Archive first,
then explicitly delete. The thread must have no running/admitted work. Deletion
fences new starts and reloads, rechecks archive/busy state inside that fence,
and locks/verifies the raw file before unlinking. Refused rechecks preserve the
loaded thread and subscriptions. Success broadcasts thread/deleted: {threadId}
to every initialized connection.
Errors: threadNotFound, threadNotArchived, turnInProgress,
threadLockedElsewhere, and archiveConflict for hard-linked transcripts;
filesystem failures return internal.
{"id":16,"method":"thread/delete","params":{"threadId":"ARCHIVED_THREAD_ID"}}server/diagnostics
Params: {}. Result: rssBytes, heapBytes, loadedThreads, loadingThreads,
payloadFiles, payloadBytes, inactiveFactories, uptimeMs,
listingFilesOpened, and listingBytesScanned. The listing counters are
cumulative: transcript scans account for file sizes and parent-header lookups
add opens, but not bytes. They exclude index reads and filesystem stat work.
Diagnostics observe the shared server, not only this connection.
{"id":18,"method":"server/diagnostics","params":{}}thread/diff/read
Params: {threadId, scope?, against?, commit?, context?, ignoreWhitespace?}.
Result: {scope, root, files, filesOmitted?, untrackedSkipped?, turnId?, stale?, against?, fromTree?, toTree?}.
files are {path, oldPath?, status, binary, additions, deletions, tooLarge?, hunks},
their paths relative to the repository's root (not the thread's cwd, which can
be a subdirectory of it); each hunk keeps its lines with their " ", "+", "-" or
"\" prefix. committed needs commit; branch compares with against,
else the worktree's base, the upstream or the default branch, and echoes it.
More than 500 untracked files are counted in untrackedSkipped and left out.
Files past the response budget come without hunks (tooLarge) or are only
counted (filesOmitted). A hunk id hashes the file's header (its blob ids) and
the hunk at the read's context: an action must pass the same context (a
context: 0 id matches no action), and after an action changes a file, read it
again, as its other hunks' ids change too. A hunk id from an ignoreWhitespace read matches no
action. Reads take no lock and never write the user's index. Unless
ignoreWhitespace is set, the result also names the two trees it compared,
fromTree and toTree, for thread/diff/file.
Errors: threadNotFound, notGitRepo; invalidParams for a missing commit
or no branch to compare with.
{"id":20,"method":"thread/diff/read","params":{"threadId":"THREAD_ID","scope":"uncommitted","context":3}}thread/diff/file
Params: {threadId, fromTree, toTree, path, oldPath?}. Result:
{old?, new?, binary, tooLarge}. Both sides of one file from a
thread/diff/read result, by the trees it named, so a client can show the
unchanged lines between its hunks. old is the file in fromTree at oldPath
(else path), new the file in toTree at path; a side the file is absent
from is left out. Neither side comes when the file is binary (a NUL in its first
8 KiB) or either side is larger than 2 MiB. The trees stay readable after the
working tree moves on, so the sides always match the read's hunks.
Errors: threadNotFound, notGitRepo; invalidParams when a tree is not a
full object id of a tree in the repository, or the file is in neither tree.
{"id":24,"method":"thread/diff/file","params":{"threadId":"THREAD_ID","fromTree":"FROM_TREE","toTree":"TO_TREE","path":"src/a.ts"}}file/read
Params: {threadId, path, maxBytes?}. Result: {path, size, binary, tooLarge, text?, mtimeMs}. A file's text, path resolved against the thread's cwd like a
tool's path. maxBytes is 1 byte to 8 MiB (default 2 MiB). text comes only
when the file is neither binary (a NUL in its first 8 KiB, or bytes that are not
UTF-8, which decoded and saved would be replaced) nor larger than
maxBytes; size is its length in bytes; mtimeMs its modification time when
read, for file/write.
Errors: threadNotFound; invalidParams when there is no such file or the path
is not a file.
{"id":25,"method":"file/read","params":{"threadId":"THREAD_ID","path":"src/a.ts"}}file/list
Params: {threadId, path?}. Result: {root, entries, truncated}. One folder's
entries for a file tree: path resolved like file/read's, the thread's cwd
when absent; root is the cwd and each entry {path, type} is relative to it,
/-separated, type file or directory (a link to a folder is a folder).
Hidden entries are included. Past 5,000 entries the rest are left out and
truncated is true.
Errors: threadNotFound; invalidParams when the path is not a folder or
cannot be read.
file/write
Params: {threadId, path, text, expectedMtimeMs?}. Result: {mtimeMs}. Writes
text (UTF-8, at most 8 MiB) to the file in place, so it keeps its mode and
links; a missing file is created. With expectedMtimeMs (the mtimeMs of the
read the text came from), a file modified since is refused with fileChanged
and left as it is.
Errors: threadNotFound; fileChanged; invalidParams when the path is a
folder or cannot be written.
thread/diff/revert
Params: {threadId, scope?, against?, commit?, path?, hunkId?, context?, reapply?, turnId?}.
With turnId (lastTurn only), a last turn other than that one answers
gitConflict diffChanged, so an Undo shown on one turn never reverts a newer one.
Result: {paths}. Reverse-applies one hunk, one file, or the whole scope to the
working tree; lastTurn without a path undoes the turn, and only while every
affected file is exactly as the turn left it. With reapply (lastTurn only,
no hunk), the turn's changes apply again instead, and only while every affected
file is exactly as the turn found it. Hunks of renamed, added, deleted,
binary or mode-changed files revert as whole files only. Refused while a turn
runs in the checkout; no turn starts and no tool runs there until it ends.
Errors: turnInProgress; invalidParams for staged and committed or a
path outside the diff; gitConflict with diffChanged or patchFailed.
{"id":21,"method":"thread/diff/revert","params":{"threadId":"THREAD_ID","path":"src/a.ts","hunkId":"HUNK_ID"}}thread/handoff
Params: {threadId, to: "local" | "worktree", branch?, environment?, dryRun?}. Result:
{thread, branch?, warnings}. To the main checkout: the worktree's HEAD
becomes a branch checked out there (the worktree's own branch, else branch,
else the previous handoff's branch fast-forwarded, else
<worktrees.branchPrefix><title>), the uncommitted changes move, and the
worktree stays, detached and clean. Back: the worktree is detached at the main
checkout's HEAD and the changes move into it; a thread born in the main
checkout gets a worktree. The changes are saved under <agentDir>/handoff/
before anything moves and leave the source only after they landed. The thread
is reloaded where it now runs: subscribers receive thread/replaced with the
same id, and queued inputs follow it.
With dryRun, the server runs every check the handoff would and answers with
the same refusal, or with the unchanged thread and the branch the main checkout
would get; it writes nothing, creates no worktree, and never holds up a real
handoff.
Errors: turnInProgress; invalidParams for a thread with no message yet or
already on that side; gitConflict with localChanges,
operationInProgress, branchExists, recoveryRequired (with data.patch)
or worktreeDeleted.
{"id":22,"method":"thread/handoff","params":{"threadId":"THREAD_ID","to":"local"}}thread/worktree/restore
Params: {threadId}. Result: {thread}. Rebuilds a deleted worktree at its
old path from its snapshot: detached at the old HEAD, with the working files
and the staged state as they were (ignored files were not kept). Then the
thread loads there.
Errors: threadNotFound; invalidParams when the worktree is not deleted;
gitConflict with worktreeMissing (deleted without a snapshot: fork the chat
into a new worktree instead) or submodules.
{"id":23,"method":"thread/worktree/restore","params":{"threadId":"THREAD_ID"}}worktree/list and worktree/delete
worktree/list {cwd?} →
{worktrees: [{path, projectCwd, head?, branch?, threadIds, lastUsedAt, deleted}]},
the managed worktrees (of cwd's project
when given). worktree/delete {path} → {snapshot?} deletes one after its
snapshot. A worktree a thread is loaded in, or one holding a nested repository
(an initialized submodule or a repository inside it), is never deleted: archive
and pruning skip it, and worktree/delete answers gitConflict with
worktreeInUse or submodules. Only registrations in the managed layout are
removed; the user's own worktrees are never touched.
{"id":24,"method":"worktree/delete","params":{"path":"/home/u/.knightcode/agent/worktrees/ab12/project"}}environment/list and environment/save
environment/list {cwd} → {environments, trusted} reads the project's
.knightcode/environments.json; environment/save {cwd, environments} writes
it (names unique, at most 50). An untrusted project's scripts never run. See
Settings.
{"id":25,"method":"environment/save","params":{"cwd":"/p","environments":[{"name":"node","setup":{"default":"cd \"$KNIGHTCODE_WORKTREE_PATH\" && npm ci"}}]}}git/*
Each acts on the thread's own checkout: its worktree, or the user's checkout.
git/status → branch, upstream, ahead/behind, staged/unstaged/untracked/
conflicted counts and an operation in progress. git/origin → the checkout's
root, the url of its origin remote (else its first remote) and the branch
checked out, each absent when there is none. git/stage and git/unstage
take {threadId, path?, hunkId?, context?}: no path is everything; paths are
literal. git/commit {threadId, message?, includeUnstaged?} stages everything
first unless includeUnstaged: false; an empty message is written by the
thread's model from the staged diff, outside the conversation, and the commit
answers concurrentChange when the checkout changed meanwhile. The user's
hooks run. git/push pushes the current branch with one explicit,
non-forcing refspec and sets the upstream the first time.
git/pr/create {threadId, title?, body?, draft?} runs gh pr create (toolMissing without
gh). git/branches, git/branch/create {name} ("Create branch here"),
git/branch/switch {name} (refused when it would overwrite local changes or an
ignored file the branch tracks) and git/log {limit?} (the commits since the
base, for the committed scope) complete the set.
Errors: notGitRepo, invalidParams, authRequired, toolMissing;
gitConflict with diffChanged, patchFailed, concurrentChange,
pushRejected, branchExists or localChanges.
{"id":26,"method":"git/commit","params":{"threadId":"THREAD_ID","includeUnstaged":true}}Ownership, unloading, and shutdown
Only one process may own a persisted session file. Symlink/junction aliases share
ownership; hard-linked transcripts are refused. threadLockedElsewhere means
another process owns it. Terminal resume offers
to fork instead. Read-only discovery and history require no ownership.
If ownership is lost to another process, the thread closes with an error
notification rather than continuing writes. Archive/unarchive owns both paths,
refuses destination collisions, and never overwrites another transcript. An
archiveConflict can include structured recovery data naming surviving files.
A thread unloads after ten minutes without a subscriber and with no running work. Unloading releases its session lock, owned virtual routes, and extension-cache leases. The factory cache retains at most 256 inactive factories plus actively leased entries. Runtime module unloading is not promised; see extension state and cache behavior.
On stdin EOF or SIGINT/SIGTERM, new input stops immediately, pending login flows
cancel, and every thread unloads. Accepted requests still receive answers; work
that cannot finish returns shuttingDown. Output drains after accepted requests
settle. At nine seconds, unfinished accepted dispatches receive shuttingDown;
late results cannot publish a second response. The final second is reserved for
output drain before the ten-second hard exit, including a client that has stopped
reading stdout. Trusted JavaScript already executing remains cooperative; the
server cannot roll back its private effects or preempt a synchronous callback.
Limits and client reference
Limits count UTF-8 bytes:
- 64 MiB per stdin line; an oversized line is dropped with an error notification.
- 64 MiB of pending unread output before the connection aborts.
- 1 MiB per history/list response, including envelope reserve.
- 64 KiB per inline turn/item; larger values use references.
- 48 KiB per payload chunk before base64 encoding.
- 64 KiB per view in a tool result or an extension message, as serialized JSON; a larger or invalid view is dropped and the raw output or text shows.
- 16 KiB per widget (its lines or view), 16 widgets and 50 statuses per thread;
status and widget keys of 1 to 128 characters; status text, title and working
message cut to 200 code points. A refused widget or status gives one
warningper key per load (a load warning when it happened while the thread loaded). - Views nest stacks and lists at most 8 levels deep.
The in-repository AppServerClient supports typed request, onNotification,
onRequest, readPayload, resolveTurn, and resolveItem calls. Its
AppServerError preserves code, message, and data exactly; inspect data
for archive recovery information instead of parsing message strings.
createInProcessClient connects the same server/client over an in-memory
transport. A host owns the shared context and ThreadManager: stop input, dispose
the context to cancel logins, and await threads.closeAll() on shutdown.
Every message is defined in app-server.schema.json. Regenerate it from the workspace:
cd packages/app-server-protocol
bun run generate:schemaRPC Extension UI
Extensions can request user interaction through ctx.ui. In RPC mode, supported calls become a request/response subprotocol alongside normal RPC commands and session events.
Message Types
KnightCode uses AgentMessage values in SDK state, lifecycle events, RPC responses, and persisted session message entries. This page defines those shared messages and their...