Skip to main content

The ledger and captures

Janus writes two kinds of record under logging.dir (default logs/, next to the config file).

LedgerCaptures
Where<logging.dir>/ledger/<day>.jsonl<logging.dir>/captures/<day>/<id>_<path>.*
HoldsOne line per request: what it was, what Janus did, what it costThe full request and response
Contains prompts or codeNoYes
Contains credentialsNoNo (headers are redacted in the render and not stored elsewhere)
Can be switched offNoYes, logging.capture
PrunedNeverBy age and total size

Days are UTC. Every file is created readable by its owner only (0600, in 0700 directories). Windows does not have those modes: there the files get whatever access the directory they are in allows, so keep logging.dir somewhere only you can read, such as under your user profile.

The console​

While Janus runs it prints a line for every request, for a person watching; the ledger is the record. The date is printed when it changes, and each line starts with the time:

── Tuesday 13 October 2026 ──
16:35:50 main Opus 5.5 ✓ 20.6s tool call 151k in · 98% cached 2.3k out $0.092
16:35:51 status Sonnet 5 ✓ 967ms stopped 84k in · 97% cached 9 out $0.022
16:36:03 subagent qwen3.8-27b (local) ✓ 14.2s answered 1.8k in 212 out free
16:36:20 main Opus 5.5 ✓ 31.0s answered 151k in · 0% cached 410 out $1.22
16:59:29 main qwen3.8-27b (local) ✓ 3.2s answered 22 in 37 out free
↳ Claude could not answer (upstream answered HTTP 429), so the local model stood in
ColumnSays
Kindmain for Claude Code's conversation, subagent, and its background requests: status (the one-line summary of what it is doing), safety (auto mode's safety classifier grading an action), quota, compact, count (token counts), ping (connectivity checks), other
Answered byThe model, (local) if LM Studio answered. Requests that are not Messages show their method and path instead
Outcome✓ and how long it took, or ✗ with the status and what the error means, or ✗ refused
StopWhy the model stopped: answered, tool call, stopped (a stop sequence), max tokens, refused, or cut off if the response ended early
InThe whole prompt, and the share read from the prompt cache
OutTokens generated
CostThe list-price estimate, worked out as janus stats does; free for local answers

Anything unusual goes on a ↳ line beneath: a local model standing in for Claude or a local answer sent on to Claude, and why; a refusal; a fault in Janus or the connection; what the guard or the local-only policy did; a transform's decision.

A secret or a local-only file in the conversation is in every request after it, and Janus deals with it every time; the ledger records each. The console says so once per conversation, and again only when something changes: another secret, another file, or a secret put back in an answer. A secret that went to Claude because redaction is off is noted every time. A conversation idle for an hour is forgotten, and told afresh.

In a terminal, color marks what matters: the main conversation in bold, subagents in cyan and Claude Code's background requests dimmed; local answers in magenta; failures in red. The cache share is green on a large prompt that came from the cache, and yellow on one that did not, which is the cache being written again at many times the price. A turn over half a dollar is yellow for the same reason. Color is off when the output is not a terminal, or with NO_COLOR set; FORCE_COLOR turns it on regardless.

The other commands follow the same rules, decided for standard output and standard error separately, and use the same colors: doctor shows ok, warn, and fail in green, yellow, and red; stats shows a usage limit in yellow from 75% used and red from 90%, failures in red and local answers in magenta; audit shows secrets sent upstream in red; route and replay color where a request went and how a replay compared; service status says running in green and not running in yellow. Output for scripts, such as -json, is never colored.

Ledger record, schema version 1​

One JSON object per line. v goes up when a field changes meaning or is removed; new fields may appear without a version change, so readers should ignore fields they do not know.

Every release so far has written schema 1, and a newer Janus reads every ledger an older one wrote: when a schema changes, records in the old one are brought up to date as they are read. A record from a newer schema than this Janus knows, as after going back to an older version, is left out of every figure rather than read as if its fields meant what they used to; janus stats says how many, and -json has them as newer_records.

FieldTypeMeaning
vintSchema version, 1
idstringUnique per request and sortable by time: 20261011T150013.033-02e426
timestringWhen the request arrived, RFC 3339, UTC
sessionstringClaude Code's session id, from X-Claude-Code-Session-Id. Omitted if absent.
agentstringClaude Code's subagent id, from X-Claude-Code-Agent-Id. Omitted for the main loop.
method, pathstringThe request line
classstringmain, subagent, connectivity, quota (Claude Code reading its rate limits at start-up with a one-token request; a 429 answer to it is normal), compaction (Claude Code asking for a summary of the conversation to replace it, as /compact does), status (Claude Code asking for a few words on what a subagent is doing, for its progress line), safety (Claude Code's auto-mode safety classifier grading an action before it runs; recognised by its system prompt), token_count (POST /v1/messages/count_tokens), or unknown
routestringWhere the request was sent: upstream, local, or shadow for a shadow run
reasonstringWhat decided it: passthrough (nothing matched), model alias <name>, rule <name>, or override <value> for a request that chose its own route. When a rule's local route could not be used, the reason says why and ends ; sent upstream. When Claude could not answer and a rule's ifDown stand-in did not answer either, it says what Claude did and why the local model did not stand in, e.g. rule main: upstream answered HTTP 429; no local stand-in: this request is about 281000 tokens, …. For a refused request, why.
stagesstring[]Changes made to the request on the way: set_model, lift_tool_additions, drop_deferred_tools, fold_system_messages, tool_references_as_text, leave_out_messages, load_model for a local request; drop_unsigned_thinking for an upstream one whose history a local model had a turn in
backendstringWhich backend answered: upstream, local, or none if Janus refused the request itself
statusintHTTP status, or 0 if no response was started
outcomestringSee below
errorstringThe API's error type, when outcome is api_error
limitsobjectFor a 429 or a 5xx: the response's retry-after, x-should-retry, and anthropic-ratelimit-* headers, by lower-case name. What the API said about when it would answer again.
ttfb_msintTime until the first byte of the response body
duration_msintTime until the response ended
requestobjectMessages requests only: model, stream, bytes, messages, tools, cache_breakpoints
responseobjectMessages responses only: model, stop_reason, blocks (content block types in order), tools (names of the tools called, in order)
usageobjectMessages responses only; see below
unreadstringWhy a Messages response has no usage, e.g. content-encoding br
quotaobjectOn a response from Claude through a subscription: how much of each usage limit is used. windows holds each limit by the API's name for its window (5h, 7d), with utilization (the share used, from 0; above 1 is over), status, and resets (when it starts again); status is the account's overall status (allowed, allowed_warning, rejected), and binding the limit the API says counts now (five_hour, seven_day). Read from the anthropic-ratelimit-unified-* headers; nothing else in them is kept. When the local model stood in, it is what Claude's refusal said. Absent with an API key, and on local answers.
handbackobjectOn Claude's first answer to a conversation's main loop after the local model stood in for it: local_turns, how many main-loop turns the local model gave, and since, when the first of them was. janus stats counts them.
faultstringWhat went wrong getting the response from the backend: it could not be reached or did not answer in time, with no fallback (Janus then answered with an error of its own), or its response broke off part-way, e.g. a dropped connection. Absent for a response that arrived whole, and for one the client abandoned.
capturestringThe capture's path under captures/, without extension
attemptobjectPresent when one backend was tried and another may have answered: backend, model, verdict, why, cause, check, duration_ms, and for a rejected attempt its usage. See below.
local_causestringPresent when a rule or a stand-in meant the request for the local model and it was never tried there: why, as one of the causes below.
guardobjectPresent when secrets or local-only content were found in a request: findings (a count), kinds (count by kind), where (which parts of the request). When redacting, also redacted (distinct secrets replaced before sending; absent means the request went with its secrets in it), restored (placeholders put back in the answer), and blocked (why the exchange was stopped). Under a local-only policy, local_only (why the request held protected content, naming paths and markers) and withheld (pieces replaced by a notice in what was sent upstream; local_only without it means the request did not go upstream). It never holds a secret or protected content. See the guard.
transformsarrayPresent when a transform rewrote the request on its way upstream, or declined to. One entry per transform: name; blocks carrying its output, of which rewritten were rewritten for the first time and revised were already sent and changed; declined (answers not used); tokens (about how many fewer tokens were sent); saved_usd (what those would have cost on this request); cost_usd (cache written again because of a revision); expected_usd (the net-savings guard's estimate, when it decided, of what this request's decisions will save over the rest of the conversation); and unpriced when the model had no price. See below.
workerobjectPresent on a record of delegated work, where Claude called a tool that the local model carried out: tool, files, read_bytes, model_calls, answer_bytes, avoided_tokens. Such a record has class worker and method TOOL, is not a proxied request, and is left out of the request totals.
shadow_ofstringPresent on a shadow run: the id of the real request it repeated on the local model. Shadow records have route shadow and an id ending -shadow, and are left out of every total.
compareobjectOn a shadow run that came back whole: upstream_stop, local_stop, upstream_tools, local_tools, stop_match, tools_match, and upstream_ms (how long the real answer took)

outcome​

ValueMeaning
okThe response completed
api_errorThe API answered with an error object; see error
http_errorA 4xx or 5xx whose body Janus could not read as an API error
incompleteThe response ended before the protocol says it should
cancelledThe client disconnected first
no_responseNothing came back from the backend
skippedA shadow run that was not attempted; reason says why
tool_errorDelegated work that did not produce an answer; reason says why

attempt​

verdictThe local model's answerWho answered the client
acceptedPassed the checksThe local model. The record's backend, response, and usage are its own.
rejectedCame back whole and failed a check; check says whichClaude. The record is Claude's answer, and attempt.usage is what the local model spent first.
failedNone to judge: it could not be reached, ran out of time, broke off, or returned an error; cause says whichClaude

attempt.backend is which backend was tried first. It is local for the cases above. It is upstream when Claude could not answer and the local model stood in: the verdict is then failed, the record is the local model's answer, and reason ends ; sent local. attempt.limits then holds Claude's retry and rate-limit headers, as limits does above, and with captures on, Claude's error body is saved as .attempt.response.txt.

Why the local model did not answer​

attempt.cause (for a local attempt that was not accepted) and local_cause (for a request passed over without trying) are one of:

CauseMeaningNext step
unreachableNo connection to LM StudioStart its server; check local.baseUrl
skippedNot tried: the local backend failed three times in a row and is left alone for 30 secondsFix what made it fail; it resumes by itself
model_missingLM Studio does not have the modelCorrect model; janus doctor lists the ids
load_failedThe model was not loaded, and loading it failedFree memory, or lower contextTokens
too_largeThe request does not fit the model's context, by Janus's estimate or by LM Studio's own errorRaise contextTokens
busyThe model already had maxConcurrent requestsRaise maxConcurrent, if memory allows
slowIt ran out of time: maxSeconds on a local-then-escalate rule, or timeoutSecondsRaise them, or use a faster model
server_errorLM Studio answered with an errorLM Studio's log
broke_offThe answer stopped part way, not by a timeoutLM Studio's log
rejectedA whole answer that failed check: empty, truncated, tools, schema, refusal, malformed, or oversizeThe model is not up to that work
unsupportedThe request could not be adapted for the local modelReport it

Records written before causes were recorded have none; janus stats works them out from why and reason, which have always had a fixed form. Such a record's verdict may say rejected for an error from LM Studio; janus stats counts it as failed.

attempt.duration_ms is how long the local model was given before the decision. For a rejected or failed attempt, that time was spent before Claude was asked, and is included in the record's own duration_ms.

transforms​

A transform rewrites requests going upstream to send fewer tokens. A block sent once is part of the cached prefix, so from then on it is sent in the same form on every turn; the net-savings guard is what decides anything else.

  • A block new to the cache is rewritten only when that makes it smaller. It saves the tokens cut at the cache write rate now, then at the read rate on every later turn.
  • A block already sent can be changed (revised) only when the guard expects it to pay: the cache is broken from that block on, and everything after it is written again. With cache reads at a tenth to a fortieth of a write, that takes many later turns, so most proposals are declined.
  • The guard estimates later turns as half the turns so far. On the 2026-10-12 ledger the full count was right on average but often far out for one conversation, and an overestimate costs more than an underestimate.
  • A transform whose revisions lose money stops revising. A revision is settled when its conversation has gone an hour without a request. Once a transform has five settled revisions, if its last twenty (or all of them, if fewer) add up to a loss, it may no longer revise, and Janus says so.

janus stats totals these per transform, as below.

worker.avoided_tokens​

The input tokens Claude would have spent reading every one of the files in full, less what the answer cost it. Files are counted at four bytes a token and the answer at three, so the arithmetic errs low.

The assumption behind it does not. It supposes Claude would otherwise have read every byte, and Claude often searches a file and reads a little of it. In the one case measured, this figure was about 63,000 and the real saving about 4,000. Read it as the most that could have been saved. The real saving is the difference in cost between sessions with and without the tool. See the measurement.

usage​

Token counts, split the way they are priced.

FieldMeaning
inputUncached input tokens
outputOutput tokens
cache_readInput tokens read from the prompt cache
cache_writeInput tokens written to the prompt cache, in total
cache_write_5m, cache_write_1hThe same, split by cache lifetime
thinkingThe part of output spent on thinking
speedfast for a fast-mode request; left out otherwise

Only input and output are always present. A field the server did not report is left out, and that is not the same as zero: LM Studio, for example, reports no cache writes at all.

Total input for a request is input + cache_read + cache_write.

LM Studio counts tokens read from its own prompt cache inside its input figure. Janus takes them out, so input means uncached input whichever backend answered. For a local answer, request.model is the name the client asked for and response.model is the model that actually ran.

Example​

{"v":1,"id":"20261011T150013.033-02e426","time":"2026-10-11T15:00:13.033Z","session":"…","agent":"…","method":"POST","path":"/v1/messages","class":"subagent","route":"upstream","reason":"passthrough","stages":[],"backend":"upstream","status":200,"outcome":"ok","ttfb_ms":654,"duration_ms":2101,"request":{"model":"claude-opus-5-5","stream":true,"bytes":47340,"messages":2,"tools":10,"cache_breakpoints":3},"response":{"model":"claude-opus-5-5","stop_reason":"tool_use","blocks":["tool_use"]},"usage":{"input":2,"output":203,"cache_read":0,"cache_write":17189,"cache_write_5m":17189,"cache_write_1h":0,"thinking":0},"capture":"2026-10-11/20261011T150013.033-02e426_v1-messages"}

janus stats​

Reads the ledger and reports where the tokens and the money went.

janus stats # everything
janus stats -since 24h # an age: 90m, 24h, 7d
janus stats -since 2026-10-01 # a local date, or an RFC 3339 time
janus stats -since 7d -until 1d
janus stats -since 7d -daily # with a row per day
janus stats -json # the same report, for scripts
janus stats -watch # today, redrawn every 5 seconds
janus stats -dir old/ledger -dir new/ledger # several ledgers as one
By class
class requests input cache write cache read output cache hit est. cost
subagent 4 8 19,693 54,938 1,118 73.6% $0.1318
main 2 4 11,309 33,772 455 74.9% $0.1063
connectivity 1 0 0 0 0 - $0.0000
total 7 12 31,002 88,710 1,573 74.1% $0.2382

The same breakdown follows by model and by backend, then a count of outcomes.

  • input, cache write, and cache read together are everything the model was sent. cache hit is the share of that read from cache.
  • est. cost is at Anthropic's first-party list prices. A subscription is not billed per token, so on one it is a measure of usage, not a bill.
  • Rows are ordered by cost, most expensive first.
  • A cost marked * leaves out requests that could not be priced, and the report lists how many and why. An unpriced request is never counted as free.

With -daily, a table follows with one row per local date: requests, how many failed (an error, an answer that broke off, or none at all; requests the client cancelled are not counted), cache hit, est. cost, local-first attempts tried and accepted, what local answers saved (below), and how many times the local model stood in for Claude. Read it to see whether a change made one day worse than the ones before. In -json it is by_day.

On a subscription, a section follows on its usage limits: each limit's utilization and reset at the last record that had a quota, and each kind of request's share of the five-hour limit used in the range. A share is the rise in five-hour utilization reported after that kind's requests; the API reports utilization in hundredths and for the whole account, so over a few requests the shares are rough and over a day they say which kinds use the limit. A fall is a reset and is not counted. In -json it is quota: at, latest, and by_class (class, added, share).

When the local model answered requests, a section says what they would have cost had Claude answered them: each priced at the list price of the Claude model it asked for (request.model), with the local model's token counts. It counts answers the local model gave by choice, by a rule or an accepted local-first attempt. Stand-ins are left out, since Claude could not answer those. A request that asked for a model with no price, such as the janus-local alias, is counted separately, never as free. The local model counts tokens with its own tokenizer, and Claude would have cached a different share of each prompt, so read the figure as the size of the saving rather than an exact one. In -json it is local_saving: requests, usd, and unpriced.

When Claude could not answer some requests, a section lists them as outages: runs of such requests none more than 15 minutes after the one before. Each gives when it began and ended, its most common cause (rate_limited for a 429, overloaded, server_error for another 5xx, unreachable), how many requests it held, how many the local model stood in for (the rest reached the client as errors), and, for a rate limit, the subscription limit reached and when Claude said it would answer again, from limits or attempt.limits. Claude Code's start-up quota check is left out, since a 429 is its normal answer. In -json it is outages. After it, a line counts the times Claude took a conversation back after the local model stood in (handbacks and handed_back_turns in -json).

When the guard was on, sections follow for what it did: secrets found and replaced, and content the local-only policy kept from Anthropic, by how (withheld with a notice, answered locally, or refused) and by its most frequent reasons. Both count a conversation's content again on every turn, since it is sent again on every turn.

When transforms ran, a table follows of what each did: requests touched, blocks rewritten, revised, and declined, tokens cut, what was saved (the tokens cut, priced at what they would have cost on those requests, less the cache revisions broke), and what the guard expected its decisions in the range to save. The two are measured differently: a decision's whole expected value is counted on the request that makes it, while it saves a little on each request after. Saved catches up with expected as conversations go on. If it stays well below, the guard is too hopeful.

It reads the ledger directory from the config file, without creating one; -dir points it elsewhere. Give -dir more than once to read several ledgers as one, such as one from before the default log folder moved and one from after; a record in both is counted once.

How cost is worked out​

Cost is computed when you run janus stats, not stored, so a corrected price applies to old records too. Per response:

cost = input x input price
+ output x output price
+ cache_read x cache read price
+ cache_write_5m x 1.25 x input price
+ cache_write_1h x 2 x input price

The cache read price is listed per model because its discount varies: a tenth of the input price on most models, a twentieth on Claude Opus 5.5, a fortieth on Claude Fable 5.1. On Claude Code traffic nearly all input is cached, so this one number moves the total more than any other.

The table was checked against a real session: Janus's figure matched the cost Claude Code itself reported to the last digit ($0.238187).

Not priced, and reported as such:

  • models with no entry in the table, which includes local models answering through the upstream route
  • fast mode requests (usage.speed is fast)
  • Claude Haiku 5.5 requests with a prompt over 100,000 tokens, which are on a different schedule

Requests answered by the local backend count as zero upstream cost.

Prices change. The shipped table carries the date it was last checked, printed in every report, and any entry can be corrected or added under pricing in the config file.

Captures​

Each request produces three files sharing a name:

FileContents
.mdThe readable render: request headers with credentials redacted, model, system prompt, tools, messages, response headers (cookies redacted), and the response assembled from its stream. For a backend tried and given up on, such as Claude at a rate limit when the local model stood in, an <attempt> section says why and gives its response headers. Start here.
.request.txtThe request body exactly as the client sent it
.response.txtThe response body, decompressed
.sent.txtOnly when Janus changed the request before sending it: the request as it went. janus audit -body prints it.

A request that was shadowed has three more files beside it: .shadow.md, .shadow.request.txt (what was sent to the local model), and .shadow.response.txt. They are pruned together with the capture.

A request whose local answer was rejected in favour of Claude's has .attempt.response.txt beside it: the answer that was thrown away.

janus replay adds .replay.md and .replay.response.txt beside a capture, holding the local model's answer to it. They too are pruned with the capture.

Captures hold everything Claude Code sent and received: your prompts, the files it read, command output. Treat the directory accordingly. The request body also contains Claude Code's account and device identifiers.

Pruning runs at startup and every 50 captures. It removes day directories older than logging.retainDays, then the oldest captures until the total is under logging.maxMegabytes. It only ever touches directories named like a date and the files inside them; anything else you put under captures/ is left alone.

The originals of digested tool results, in originals/ (see CONFIG), are pruned by age only: at startup and then at most once an hour while Janus runs, each is deleted once it has gone unused for logging.retainDays. They don't count towards logging.maxMegabytes, because a digest promises its original back for that long.

A body larger than 32 MB is cut short in the capture, and the render says so. The request and response themselves are always relayed in full.