Logging: audit and diagnostics
wisp keeps two logs with different jobs. The audit log is the record of what the agent was asked, what it decided, what it ran, and what came back, written verbatim for the user alone. The diagnostic log is for debugging wisp itself and goes through Apple’s unified logging.
Audit log
Location: ~/.wisp/logs/audit.jsonl (under $WISP_HOME when set). JSON Lines, one event per line,
mode 0600, rotated by size to audit.1.jsonl … audit.N.jsonl. Every entry point writes to it: respond,
chat, and mcp. It is on by default; config.json controls it:
{ "audit": { "enabled": true, "maxFileBytes": 10485760, "keepFiles": 5 } }
Content is stored verbatim by decision (prompts, replies, commands, tool output). Treat the file as sensitive; it is why it is user-only.
Event envelope
| Field | Meaning |
|---|---|
schema | Event schema version, currently 1. |
id | The event’s own id: 16 lowercase hex characters, random. A conversation’s store refers to the events that recorded its entries by this id rather than copying their content (context-management.md, “The store and the composer”). Absent from lines written before ids were added. |
time | ISO 8601 UTC with milliseconds. |
version | wisp version that wrote it. |
pid | Process id, to separate concurrent wisps sharing a file. |
session | A CLI run, a chat, an MCP server, or an MCP thread (its thread_id). |
turn | 1-based turn within the session, present from the first prompt on. |
call | Pairs a tool.call with its tool.result, or an mcp.request with its mcp.result. |
kind | One of the kinds below. A line whose kind this build does not know (written by another release) is shown as it is, by wisp logs and the audit resources, and is not skipped. |
details | Kind-specific fields. |
Kinds and their details
| Kind | Details | Written by |
|---|---|---|
session.start | entryPoint (respond, chat, mcp, mcp-thread, notify, scan, redact, watch, draft, classifier, config, approvals, facts, questions, models), systemPromptExtension and instructions (the operator’s and the caller’s layers, null when absent; wisp’s own prompt is fixed per version), tools, model, unsafe, autoApprove, resume, and on a resume whose saved store links entries to audit events (context-management.md) carriedFrom (the distinct ids of the sessions those events belong to, sorted; absent otherwise); an MCP thread adds parent (the server session’s id), and commandTimeoutSeconds when respond started it with one (as asked, before the cap; ADR 0063), and records the same fields through Session.conversation; a chat /new records reason (new), tools, and model only, from Agent.reset. A start that swept the output store adds outputSweep (ADR 0050, 2026-10-12): removed (the audit sessions whose stored outputs it removed, last used more than context.outputDays days ago, oldest first), bytes (what their files took), and deferred (sessions old enough that the bound, 16 a start and 100 ms, left for a later start); absent when it removed nothing and left nothing | Session, Agent |
session.end | reason: closed (explicit, or a triage-<id> session finishing), evicted (least recently used thread dropped at capacity) | CLI, MCP |
model.resolved | model (the selection), backend (system, private-cloud, or a scheme), asset (what backs a local model, null for Apple’s), capabilities (declared names), capabilitySource (framework, runtime, configuration, undeclared), tools the conversation opened with, contextSize (the window, null when unknown), contextNote (why, when the backend chose it, such as “24,576 of 131,072: 9.2 GiB of a 9.8 GiB budget”, or, for llamacpp and lmstudio, “reported by llama.cpp (/props n_ctx): …”; the asset is then the base URL and the server’s id for the model, never its API key, ADR 0058); recorded when a conversation opens, after the capability check | Conversation |
model.fallback | model (the configured model chat wanted), reason (why it was unavailable, as its backend said), fallback (the model chat started on instead, system); recorded before the fallback’s model.resolved, only when wisp chat (or wisp-tui) starts and its configured model, not one named with --model, is unavailable. respond and MCP never fall back (ADR 0056) | Conversation |
prompt | text; schema (the caller’s JSON Schema) when the reply had to be shaped; think (true, false, or a level) when the turn’s requests send the model a thinking switch: the conversation’s choice (respond’s think), false for a schema reply that made none, else the model’s configured default (<runtime>.models.<name>.think, then <runtime>.think); absent when nothing is sent, as to a model that cannot think (ADR 0053, amended 2026-10-11) | Agent |
turn.stopped | seconds (from the prompt to the stop) and killed (true when a second Ctrl-C or interrupt killed what the turn still ran rather than letting it end within the grace): the person stopped the turn with Ctrl-C in chat or an interrupt line from a front end (ADR 0049, amended 2026-10-11). The request was cancelled and a run_command it started stopped, whose command.outcome has stopped true. Recorded instead of the turn’s response or error; the turn’s prompt and its calls with their outputs stay in the thread | Agent |
response | text, condensed, seconds, and usage (input, output, cached, reasoning: the tokens the turn’s requests used, as the runtime reported them) when the runtime reports usage (Ollama, MLX under wisp’s executor); absent otherwise | Agent |
model.reasoning | A reasoning model’s thinking within one request (ADR 0053), recorded at both edges by the turn’s observer: phase start, with nothing else, as the first chunk of thinking arrives, so a face can say it is thinking; phase end as the reply or a tool call begins (or the stream ends), with text (the thinking verbatim), its size in bytes, tokens (the runtime’s count: for Ollama, one per streamed chunk, as it reports none of its own), and seconds. Recorded only for requests inside a turn; a distillation’s or assessment’s thinking is not. The conversation’s store refers to the end event for the turn’s reasoning entry, which no request carries back to the model; chat shows the text folded under a ∴ thought for … line, /show and /inspect thinking show it whole, and wisp://threads/{thread_id}/reasoning serves it | Agent (through ReasoningObserver) |
tool.call | tool, arguments (JSON as the model produced it) | AuditedTool |
tool.result | tool, output (what the model was given: within the tool-output budget, its first and last lines and a held back: note when the whole was larger, ADR 0050), bytes, seconds, and repeat (which call of the same tool with the same arguments this is in the turn, from 2; absent for the first) ; an MCP respond result’s calls name this event’s id, and wisp://threads/{thread_id}/output/{id} serves its output, or the whole stored output (mcp.md); chat’s fold line names its start for /show | AuditedTool |
output.kept | A tool output larger than the tool-output budget, kept whole in the conversation’s output store, ~/.wisp/output/<session>/<call> (ADR 0050): tool, bytes (in the file), lines, sha256 (of the file), cut (whether the output passed the 16 MiB ceiling, so the file holds its first bytes only), totalBytes (the output’s size in all). Carries the tool call’s call id, which is the output’s id in the store, the one the result’s note names; recorded after tool.call and before tool.result, which holds what the model saw. Never the content: the log does not grow with the output | OutputStore |
policy.decision | command, workingDirectory, verdict (allowed, denied by pattern, disapproved by the gate), reason, sandbox, network, nested; recorded once, after the directory check, patterns, and approval; origin: "person" for a command the person typed in chat after ! or !!, which the gate does not see (ADR 0049); absent for the model’s; when the model asked to run it outside the sandbox (ADR 0064), or the person typed it after !! (amended 2026-10-14, where “asked of the person” does not apply: typing it was the approval), outsideSandbox: true where the request applied (the line was asked of the person and, allowed, ran without wisp’s sandbox, sandbox false; or denied because commandPolicy.sandbox.allowOutsideSandbox is false), or outsideSandboxIgnored (sandbox-off or nested) where it changed nothing; both absent otherwise | CommandRunner |
command.outcome | command, exitStatus, timedOut, truncated, stdout, stderr, seconds; origin: "person" for a typed command, as for policy.decision; stopped: true for a typed command the person stopped (Ctrl-C in chat, interrupt from a front end; absent otherwise): its group was sent SIGTERM, then SIGKILL two seconds later, and exitStatus is the signal negated, -15 or -9 (ADR 0049, amended 2026-10-09); for a model’s command, the time limit it ran under (ADR 0063): timeoutSource (call for run_command’s timeoutSeconds, thread for respond’s commandTimeoutSeconds, default for the operator’s commandTimeoutSeconds, or a custom tool’s own timeoutSeconds, or system_info’s floor of 30 s), timeoutSeconds (the limit applied, after the cap; absent when there was none, a commandTimeoutSeconds of 0), and timeoutClamped: true when a request above commandMaxTimeoutSeconds was cut to it (absent otherwise); a typed command, which has no limit, records none of the three; for a confined command that failed with Operation not permitted, or whose error output says a directory is not writable (Homebrew checks its directories before it writes, and says so instead; amended 2026-10-14), sandboxRefusal (ADR 0054): refused (a path its error names is outside every writable root, checked as a real path), not-the-sandbox (every path named is inside them), or guess (no path to check: a network connection, a process, a file the error does not name), with the paths checked in sandboxPaths (real paths, at most 8; only those outside the roots for refused). The model’s run_command result carries the same verdict as a short sandbox: line; outsideSandbox: true for a command that ran without wisp’s sandbox because the model asked and the person approved, or the person typed it after !! (ADR 0064, amended 2026-10-14; absent otherwise) | CommandRunner |
command.typed | A command the person typed in chat or wisp-tui after ! (ADR 0049), recorded after its policy.decision and command.outcome: command (the line without the !), workingDirectory, verdict (allowed, or denied with the policy’s reason), output (what it printed, stdout then stderr, as chat shows it) and its size in bytes, seconds; for one that ran, exitStatus, timedOut (always false: a typed command has no timeout since 2026-10-09), truncated, stopped: true when the person stopped it (absent otherwise), and sandboxRefused (the sandbox refused it or may have: command.outcome’s check found refused or guess), with that check’s sandboxRefusal and sandboxPaths, and stored, the output store’s id for the whole output when part of what it printed was held back (ADR 0050, absent otherwise); for one allowed that could not start (a missing directory), failure; outsideSandbox: true for a line typed after !! (ADR 0064, amended 2026-10-14), whether it ran outside or was refused or changed nothing (policy.decision and command.outcome say which; absent otherwise), and the summary line marks it !!. No classifier verdict or approval comes before it: typing the command is the approval. The conversation’s store refers to this event for the entry that tells the model of the command, chat’s fold line names its start for /show (which shows the stored whole), and memory recalls the output from it | Agent |
secrets.scan | source (command and workingDirectory, path, or stdin), bytes, diff, thorough, findings (a count), kinds (count per kind), failedChunks (chunks the model failed on twice, checked by rule only), classifier (the personal-data classifier’s personal@<version>, why it was unavailable, or null without personal data); never a value or a preview | WispServer, wisp scan |
redaction | source, bytes, bytesOut, truncated, thorough, replaced (occurrences per kind), failedChunks; never a value | WispServer, wisp redact |
model.routed | task, inputBytes, model, reason (the measurement that vouched for the model, or why none did, and any fallback; for a task default, whether it is wisp’s measured default or routing.tasks); one per routed call | WispServer, wisp draft, wisp scan, wisp redact |
model.pull | model (the selection it becomes, mlx:<name>), repository, directory (the link’s path in the MLX models directory), cache (the Hugging Face cache’s snapshot directory it links to), files and bytes (what the repository’s listing gave for the wanted files), reused (files the cache already held, checked by size and, for weights, SHA-256), seeded (files copied into the cache from a real directory at the link’s path, checked the same way, instead of fetched), fetchedFiles and fetched (files and bytes this run downloaded; a resumed file counts only the bytes fetched after its part), link (what happened at the link’s path: created, unchanged, replaced link, replaced directory when the person agreed to move a real directory to the Trash, kept directory when they did not; null when the pull stopped before linking), outcome (fetched, linked when nothing needed downloading, declined when the person did not answer yes to the download or refused the publisher, or did not approve the model’s request, failed, cancelled when Ctrl-C stopped it in chat or a background pull was cancelled, what arrived kept), reason (why it failed, was cancelled, or was not approved), seconds (from the answer, or from the plan when nothing was asked, to the end) (ADR 0052), source (the face that asked: cli, chat, or tool for the model’s models tool), approval (for the model’s request, the id of the approval that allowed it, as approval.decided’s approvalID; null otherwise) (ADR 0059). Enabling a cached MLX model that is not linked (wisp models enable, /models enable, the wisp-tui picker, the models tool) records one too, with outcome linked, reused and files the snapshot’s files, and nothing fetched (ADR 0056). A pull the model asked for is recorded in its conversation’s log when it ends, in the background | wisp models pull and wisp models enable, entry point models; /models in chat; the models tool, in the asking conversation |
model.publisher | Who published a repository wisp models pull was asked for, and whether it went on (ADR 0052, amended 2026-10-09): repository, publisher (the organisation), decision (trusted when mlx.trustedPublishers holds it or it is mlx-community; flag for --trust-publisher; once and trust for the person’s answers, trust also adding it to the setting, recorded just before as a config.change; refused when the person answered anything else or nothing, or could not be asked), asked (whether the question was put), licence (what the Hub’s model information gave, null when it gave none or nothing was listed), bytes (what the pull would download; null when refused before the listing), reason (no terminal for a refusal without asking; null otherwise), source (cli, chat, or tool), approval (for the model’s request, the approval’s id; its decision is trusted, or once for a publisher not trusted, since the person approved that pull alone; null otherwise; ADR 0059). One per pull once the repository is listed (for the model’s, once approved), before its model.pull, and one for a publisher refused without a terminal, before any request, which has no model.pull | wisp models pull, entry point models; /models pull in chat; the models tool |
model.verified | model, trigger (enable when enabling a model with no declared capabilities ran it, check for wisp models check and /models check), outcome (refused: the reply failed, nothing was recorded, and enable kept or made the model disabled; text only: the reply passed and tool calling did not, so it is usable only with tools off; usable), checks (one object per question asked: check (reply, toolCalling, guidedGeneration), passed, detail (the reply, the call, or why it failed, such as no call to record_word arrived or no answer within 60 s), seconds), recorded (the capabilities written to mlx.models.<name>.capabilities; null when the reply failed and nothing was recorded), kept (capabilities declared by hand whose check failed, kept), removed (capabilities an earlier check recorded that this one failed, taken out), unchecked (reasoning, vision), seconds (all the questions, the weights’ loading included), source (cli, chat, or tool), approval (for the model’s request, the approval’s id; null otherwise; ADR 0059). Followed by a config.change when something was recorded, and, from enable, one of models.disabled when the outcome turned the model on or kept it off (ADR 0056, refined 2026-10-04) | wisp models enable and check, entry point models; /models enable|check and the wisp-tui picker in chat |
watch.run | command, run (from 1), trigger (start, change, interval), exitStatus, timedOut, state (pass, fail), previous, changed, seconds, findings (a count, or null when not triaged), triageError, notified; one per run of wisp watch | wisp watch |
notification | title, body (both as bounded for display), source (model, user, watch, approval, inquiry), outcome (posted, refused), route when posted (host, terminal, app, osascript; ADR 0044), reason when refused, and skipped when an earlier route was passed over or failed: one short route: reason each, such as terminal: Terminal.app has no notification sequence; one per request from the notify tool, wisp notify, wisp watch, or a command waiting for approval under wisp mcp (approval, ADR 0046), or the model’s question waiting there (inquiry, ADR 0062). A request refused before any route (off, empty, over the limit) has neither route nor skipped | Notifier |
host.hello | effects (as declared, such as approve, notify), client and version when sent; one per hello line from a wisp chat --json front end | wisp chat --json |
file.write | path, mode (write, append, replace), created, bytesBefore, bytesAfter; recorded after an edit_file edit lands, the content being in the tool.call arguments | EditFileTool |
context.condensation | turnsBefore, turnsAfter (the model’s turns, not counting commands the person typed after !), contextSize, tokenCount, savedBefore and savedAfter (the Markdown files holding the transcript before and after, when audit.enabled), reason (overflow: the model refused the prompt and the retry follows; budget: the transcript’s size (the last request’s reported usage, or the model’s own count when it reports none) plus the new prompt, and under the default policy the headroom for the next turn, would pass contextBudget of a known window, so the transcript was condensed first); the store marks the entries it dropped with this event’s id. Condensing to a target (the default policy; context-management.md, “Condensing”) adds, all in tokens: target (the goal it condensed to: the target share of the window, capped at the budget less 0.2, or less when the prompt and the headroom need more room under the budget), fillBefore and fillAfter (the context before and after, by the model’s count or an estimate anchored on the runtime’s figure), headroom (the average turn kept free), steps (in order, each one of referenced N, distilled N turns (only when a distillation ran and succeeded), dropped N turns, squeezed earlier; empty when nothing could change, and then a failed turn’s receipt does not say condensed), and floor: true only when even the last turn left the context above target. That event is recorded after the steps, so a condensation’s context.distillation and context.summary come before it; one at the floor that changed nothing, recorded so the note to the person has its record, has empty steps and is not counted as a condensation | Agent |
context.cut | entry (the reply’s id in the conversation’s store), output (the store id of the tool output it reproduced), tool, response and result (the ids of the response and tool.result events that recorded the reply and the output, when linked), bytes (UTF-8 bytes the cut removes from later requests, net of its marker), tokens (bytes at four bytes a token, an estimate), words, coverage (the fraction of the stretch’s lines found in the output: always 1, since only exact copies are cut); one per stretch of presentational text, recorded after the turn. The reply shown and stored is unchanged; later requests carry a marker in its place (context-management.md, “Output handling”) | Agent |
context.reference | entry (the tool output’s id in the conversation’s store), tool, result (the id of the tool.result event that recorded the output, when linked), bytes (the output’s UTF-8 bytes), referenceBytes (the reference’s), tokens (bytes less referenceBytes, at four bytes a token, an estimate of what each later request saves); one per output, recorded at the start of the turn after the output’s, when requests begin to carry a reference in its place. The output shown and stored is unchanged (context-management.md, “Output handling”) | Agent |
context.distillation | turns (the numbers of the turns distilled), entries (their prompts and replies), bytes (the distiller’s prompt), facts (the ids of the facts it recorded), seconds, model, failure (why it failed, when it did; the turns are dropped as before and the turn goes on); one per pass of a condensation of a conversation that keeps facts, recorded after its context.condensation under phase 2’s fixed turns and before it when condensing to a target. The call runs in a session of its own and never enters the conversation (context-management.md, “Facts”). think is false when the model has a thinking switch: the call asks a model that can think not to (ADR 0053, amended 2026-10-11); absent otherwise | Agent |
context.summary | version (the running summary’s new version, from 1; absent when none was written), turns (the numbers of the turns it adds), entries (their prompts, tool calls, and replies), bytes (the prompt), covered (how many turns the new version covers, the earlier versions’ included), summaryBytes (its size), seconds, model, combined (whether the call also distilled the facts; its context.distillation then has the same time), failure (why no version was written: the call failed, or the answer was empty; the summary stays as it was, and the turns wait for the next batch); one per condensation that brings the dropped turns not yet summarised to a batch (three by default), recorded after its context.distillation. The call runs in a session of its own and never enters the conversation (context-management.md, “The running summary”). think, as for context.distillation | Agent |
context.memory | request (the memory call’s argument as the model wrote it), action (recall, summarise, condense, note, or task). A recall adds target (entry, turn, task, summary, fact, or output for a stored output, with its id as output), found, entries (the store ids of the entries whose content it returned), facts (the fact ids), summaries (the running summary’s versions), events (the ids of the audit events the content was read from), from (audit when every entry’s content came from the audit log, store when from the conversation’s store because the audit did not hold it, audit+store for a mix; absent when it returned no entry), filter (matching, lines, head, tail; absent for none), offset (the page’s first line), bytes (the result’s). A summarise adds target, entries or output, found, summary (notable, overview), bytes; a condense adds the same, chunks (the chunks the model read), and failure (shape for no question, target, off for no model attached, or how many chunks failed). A note adds noted, and when kept subject, name (normalised), value, and class (permanent makes it a proposal); when refused, failure (shape, subject, name, off, full). A task adds noted, and value (the task and its objective as kept) or failure (shape, off, full, or pinned when the person or a caller set the task). One per memory call, after its tool.call and before its tool.result, which holds the text itself; it names what was restored or noted, which the text alone does not. A note kept is recorded as fact.recorded with method noted when its turn ends (tools/memory.md) | MemoryTool |
context.assessment | method (rules when the rules settled the request, model when one call to the model did, fallback when that call failed and every allowed tool was registered with the task unchanged, retry when the model called a tool the request had not registered and the request was retried with every allowed tool), tools (the tools chosen, in the conversation’s order), ruleTools (those the rules gave on their own; for a retry, the selection that missed), registered (the tools the request’s session registers, or all), taskChanged, task (the task fact recorded, when it changed), facts (the ids of the facts repeated next to the request), seconds, bytes (the model call’s prompt; 0 with no call), model (when a call was made), intent (the person’s intent in one line, as the model put it; audited, never sent), failure. One per user turn when assessment.enabled is true, after prompt and any context.reference, before the turn’s tool calls, plus one per retry; never in the model’s context (context-management.md, “The assessment per request”). think, as for context.distillation, when the model was asked | Agent |
response.verification | The verification pass over a reply (verification.md, ADR 0060): method (readers, or model when the model step spoke), status (verified, or unsupported when a claim was), claims (each reader, the reader’s name or model, claim as quoted from the reply, and reason), calls (how many calls of the turn were checked against), seconds, and for the model step bytes (its prompt), model, think (what its request sent), and failure. One per reply that made a claim to check, after response; none for a reply with no result language, under verification.mode off, or in a conversation without a tool-event trail | Agent |
fact.recorded | id (c… for the conversation’s, s… the session’s, p… the shared store’s), scope, subject, name, source (person, caller, tool, model), version, value, class (permanent, dynamic, ephemeral), method (stated, extracted, distilled, noted for the model’s memory note or task, inferred for a task the assessment inferred), detail (the tool, or who spoke), entries (the store ids it came from), sources (the audit event ids of those entries), supersedes (the fact it replaced, when it is a new version) | Agent |
fact.superseded | id, subject, name, source, by (the fact that replaced it: a newer version from the same source, a fact from another source that holds the same value, or its copy in another scope after a move) | Agent |
fact.deleted | id, subject, name, source, value, by (person); the store keeps the fact, marked deleted, and later requests leave it out | Agent (/fact delete) |
fact.scope.changed | fact (the id it was named by: c3, or git/c3 for another conversation’s proposal), from and to (permanent, thread, session), by (person in chat, caller over MCP, person again when the person answered a caller’s request to keep it), now (its id after the move; the same for a proposal moved to thread, which changes in place), subject, name, source, value, proposed (whether it was a proposed permanent fact), request (the pending request the person answered, when there was one: kept, or dropped and so moved to thread; ADR 0048); recorded on the log of the conversation that moved it, and on the proposing conversation’s when it moved another’s, with a fact.superseded for the old copy | Agent (/fact ID SCOPE, set_fact_scope) |
fact.conflict.raised, fact.conflict.resolved | subject, name, winner (the head that wins by precedence), others (the heads that disagree with it; empty when resolved); recorded when the current heads of different sources about one subject and name begin or stop disagreeing | Agent |
mcp.request | tool, arguments | WispServer |
mcp.result | tool, isError, text, seconds | WispServer |
error | message, context | anywhere |
classifier.verdict | command (one simple command), pattern, line (when the command is part of a longer line), level, reasons, sources, seconds, metadata (when a classifier adds facts: coreml.model, coreml.version, coreml.contract, coreml.label, coreml.confidence, and coreml.fallback with the reason when the verdict is a fallback; classifier.failure, with the reason, when a model classifier could not judge and fell back to moderate; classifier.cached: true when the session reused an earlier verdict for the same line and directory; rules.knownSafe: true when the rules know the command to be read-only and no model was asked; think: "false" when a model classifier’s model has a thinking switch, which a verdict asks it not to use, ADR 0053 amended 2026-10-11) | ApprovalGate |
classifier.train | path (the model written), examplesSource (bundled or the file), examples, perLevel (examples per level), trainingAccuracy (the fraction of its own examples it labels back, a sanity check, not a measurement), seconds | wisp classifier train |
config.change | path (the setting), old and new (its values, null when unset), source (chat, cli, or tool for a change the model’s models tool made once approved), approval (for the model’s request, the approval’s id, as approval.decided’s approvalID; null otherwise; ADR 0059) | /config set and unset, wisp config set and unset; models.disabled also from /models enable|disable, the wisp-tui picker (chat), and wisp models enable|disable (cli, entry point models); mlx.models.<name> (the whole declaration, capabilities and verified) after a capability check records what passed, from the same places and wisp models check and /models check; mlx.trustedPublishers when the person answers t to wisp models pull’s question about a publisher (cli, entry point models), or trust in chat’s dialog or with /models trust (chat); models.disabled and mlx.models.<name> from the models tool too (tool, in the asking conversation’s log) |
approval.requested | command, pattern, line, level; outsideSandbox: true for a line the model asked to run outside the sandbox, asked whatever its level (ADR 0064) | ApprovalGate |
approval.pending | Under wisp mcp, a command waiting for approval filed for another face to answer (ADR 0046): request (the id wisp approvals approve takes), command, pattern, line, directory, level, thread, client, expiresAt (absent when approval.timeoutSeconds is 0), alongside (elicitation when the client’s dialog asks at the same time), bannerDelaySeconds (with alongside, when approval.bannerDelaySeconds is above 0: the notification waits that long for the dialog’s answer and is never posted if the request settles first; absent when it is posted at once), outcome (filed, or failed with reason, when the channel could not be used), outsideSandbox: true for a line the model asked to run outside the sandbox (ADR 0064). A fact a caller asked to keep as a permanent fact (ADR 0048) is filed the same way, with kind (fact), fact (its id), subject, name, value, and source in place of command, pattern, line, directory, and level, and no alongside | OutOfBandApprover, FactKeeper |
approval.answered | The person answered a waiting request in this process, recorded under that process’s own session (wisp approvals, entry point approvals; the wisp chat --json session behind wisp-tui; or a plain wisp chat, answering with `/approvals approve | denyor/facts keep |
approval.settled | How a filed request ended, on the asking thread: request, command, thread, outcome (answered with via: elicitation, cli, tui, chat, and decision; timed-out; abandoned when the caller cancelled the call; failed with reason when no way was left to ask; stale when a sweep removed it after its server stopped or its wait expired, recorded by the sweeping process), seconds waited, banner (posted when the request’s notification was posted, skipped when the request settled within bannerDelaySeconds, so none was ever posted; the notification event itself is recorded when it is posted, after approval.pending, or not at all). approval.decided follows, as for every face. For a fact to keep: kind (fact), fact, subject, name, value, source in place of command; decision keep (with kept, the permanent fact’s id) or drop; no timed-out, since a fact request has no time limit; handed-over when the server stopped first and left the request filed; failed with reason when the answer could not be applied (the fact changed after the person was asked); no approval.decided follows, and a keep is also a fact.scope.changed. An answer that comes after the thread closed is recorded on the server’s own log, and one applied by the answering process after the server stopped (reason saying so) on that process’s log. withdrawn appears only in logs from 0.22.0 and earlier | OutOfBandApprover, FactKeeper, wisp approvals, wisp facts |
inquiry.requested | The model asked the person a question through its ask tool (ADR 0062): inquiry (the question’s id, which wisp questions answer takes), question, options (empty for free text), ways (how the face asks: terminal, json, elicitation, pending; empty when nobody can be asked), timeoutSeconds (the deadline, absent when it waits until answered), call (the ask call’s id), thread | AskTool |
inquiry.parked | Under wisp mcp, the dialog’s hold lapsed and the turn ended with the question still waiting (D15): inquiry, question, waitedSeconds (how long the call held the dialog). A parked question is not settled until the person answers, declines, or it is withdrawn | AskTool |
inquiry.answered | The person answered a question in this process, recorded under that process’s own session (wisp questions, entry point questions; the wisp chat --json session behind wisp-tui; or a plain wisp chat, answering with `/questions answer | decline): inquiry, via (cli, tui, chat), delivery (takenby the server,too-latewhen the thread closed first,waitingwhen not yet read,refusedwithreasonwhen the answer was not written: unknown, stale, altered, already answered, not an option). The waiting server records one withdelivery refused` when an answer file was not bound to its question |
inquiry.settled | How a question ended, on the asking thread: inquiry, question, thread, outcome (answered, none, declined, unanswered (only past a deadline set), stopped (Ctrl-C in chat), withdrawn (the thread closed, the server stopped, or a sweep removed it), abandoned (the call was cancelled), refused (a secret, a limit, or nobody to ask, with reason), failed with reason), via (where the person answered: terminal, json, elicitation, cli, tui, chat), delivered (tool: the answer was the tool’s result; notice: it reached the thread’s next turn after a park), answer (verbatim, with the markers of masked values; none of these for none), redacted (the kinds of credential-shaped values masked), seconds waited. The ask call’s own tool.call and tool.result come as for every tool, the result holding the answer the model got | AskTool, InquiryBox, wisp questions (a sweep) |
approval.decided | command, pattern, line (when the command is one part of a longer line), decision (approved with scope, denied, timed-out, cached-turn for a once-approval reused within the same turn, cached for session, cached-project/cached-always for persisted, covered-line for a part at or above the threshold that was not asked because the whole line, which the rules rated above every part, was approved or covered just before it; its own line names that line), reason, approvalID, expiresAt, downgradedFrom when a dangerous command’s persisted scope was reduced to session (or, for a run outside the sandbox or a model pull, the scope asked for when it was taken as once), persistError when the store could not be written, outsideSandbox: true for a line the model asked to run outside the sandbox, which is approved once or not at all (ADR 0064) | ApprovalGate |
Every tool the model can call is wrapped by AuditedTool, so a new tool is audited without doing anything.
Each row’s fields are spelled once, in AuditEvent.Details (one constructor per kind) and
AuditEvent.fields(for:); a test checks every constructor against that set, so this table and the
code cannot drift silently. A new field goes in all three places.
Reading it
wisp logs prints one-line summaries, newest last, across rotated files:
wisp logs # everything
wisp logs --session 5fd0cf21 # one session
wisp logs --kind tool.call --kind tool.result --tool run_command
wisp logs --last 20 --json | jq . # raw events
A session’s events group on session; a tool call and its result share call. Saved transcripts in
~/.wisp/transcripts hold the same conversation in the framework’s own format.
In code
AuditLog records events for one session and stamps turn numbers from the conversation’s TurnClock; AuditSink is where they go
(FileAuditSink, MemoryAuditSink for tests, NullAuditSink when disabled). Sibling logs for other
sessions share a sink via log(forSession:). Events are AuditEvent values; add a Kind and document it
here. Tests assert on MemoryAuditSink.events.
Diagnostic log
Every component logs through Diagnostics.<category> (agent, tools, policy, mcp, chat, audit)
to unified logging under subsystem com.pidster.wisp. The MCP SDK’s swift-log output is bridged into the
mcp category.
log stream --predicate 'subsystem == "com.pidster.wisp"' --level debug
WISP_LOG=debug wisp "…" # also mirror to stderr: debug, info, or error
Messages are marked public so they are not redacted. Nothing ever goes to stdout, which stays the MCP protocol channel.