Local-first log sink · searchable UI and API · agent-ready MCP
Your logs, readable by you and your agents.
One local process collects the logs your app routes to it. Full-text search, faceted filters and a live tail you can pause — plus an MCP endpoint, so your coding agent searches the same logs instead of asking you to paste your terminal.
- Service
- all
- api-gateway
- checkout
- worker
- postgres
- Severity
- all
- error
- warn
- info
- debug
Interface preview — live tail, multi-select facets, structured attributes. Interface preview — live tail and structured log rows.
The problem
You find logs by filtering and searching — not by scrolling a mile back.
Three services, three terminal tabs, interleaved output, and Cmd-F pretending to be a search engine. Most teams eventually build a scrappy internal viewer for this. localsink is that tool, built properly.
14:31:44 api {"level":30,"msg":"GET /v1/cart 200","dur":41,"req":"8f2c"}
14:31:44 worker reconcile:8812 attempt=2 queue=emails depth=1284
14:31:45 api {"level":40,"msg":"slow request","dur":1420,"route":"/v1/cart"}
14:31:45 checkout {"level":30,"msg":"cart validated","items":3}
14:31:46 worker reconcile:8812 attempt=3 queue=emails depth=1291
14:31:46 api {"level":30,"msg":"POST /v1/checkout","req":"8f2c"}
14:31:47 checkout {"level":50,"msg":"payment intent rejected","code":"card_declined"}
14:31:48 api {"level":50,"msg":"upstream timeout after 30000ms","route":"/v1/checkout"}
14:31:49 postgres {"level":50,"msg":"deadlock detected on relation orders","pid":4471}
14:31:49 api {"level":30,"msg":"GET /v1/cart 200","dur":38}
14:31:50 worker reconcile:8812 attempt=4 queue=emails depth=1302
For agents
Stop copy-pasting your terminal into a chat window.
Your agent writes the code and runs the app — then goes blind, because the output only exists in your terminal. localsink exposes its whole query surface over the Model Context Protocol at localhost:3000/mcp. Point Claude Code, Claude Desktop or any MCP client at it and the agent goes and finds the error itself: searchable and filterable, with the ability to poll forward for what's new.
The agent closes its own loop
Change code, re-run, search the logs, verify the fix — inside one turn, against what the app actually emitted rather than a guess.
Structured results, not screen scrapes
Levels, services, attributes and timestamps come back as data. Far less context burned than pasting a raw log file into the window.
Written for models, not just for humans
describe_logs tells the model to call it first to discover the service and level names that actually exist — so it filters on real values instead of inventing them.
One query surface, two front doors
The UI and the MCP tools share the same query layer, so they can’t drift apart. Everything the UI can do is a tool call.
trace_id 8f2c…. The gateway timed out
waiting on
checkout, which was blocked by a deadlock on orders — from the transaction you widened in reserveStock().
Want me to narrow it back and re-run?
What services, levels and loggers exist, how many logs, what time range.
The full filter and full-text query surface, same as the UI.
Drill into one row: attributes, error, stack, metadata.
Install
Two steps. Both of them tiny.
A single Node process and a SQLite database next to it — that's the whole deployment. No Docker Compose, no collector sidecar, no JVM, no cloud account, no API key.
Starts on localhost:3000 with the web UI, the REST API and
the MCP endpoint. Ctrl-C stops it.
import pino from 'pino';
const logger = pino(
pino.transport({
target: '@localsink/pino',
options: { serviceName: 'api' },
}),
);
import { createLogger } from 'winston';
import { LocalsinkTransport } from '@localsink/winston';
const logger = createLogger({
transports: [
new LocalsinkTransport({ serviceName: 'api' }),
],
});
import { localsink } from '@localsink/console';
localsink({ serviceName: 'api' });
// two lines — and console.log still prints to stdout
import { createClient } from '@localsink/sdk';
const client = createClient({ serviceName: 'api' });
await client.log({
timestamp: Date.now(),
level: 'info',
message: 'hello',
});
One import, one call. serviceName is the only required option;
url defaults to http://localhost:3000.
Search
Real full-text search, not substring matching.
SQLite FTS5 over your whole log store — message, error JSON and attributes JSON, recursively through nested structures. Attribute keys are indexed too, so searching user_id finds every log that carries one.
Queries the whole store, not the screen
Search runs server-side against everything collected — not a filter over the rows that happen to be rendered.
Faceted filters from your actual data
Service and level facets are discovered from what you’ve sent. OR within a group, AND across groups: pick api + worker and warn + error, get exactly that intersection.
Messy queries still match
Type something that isn’t valid search syntax — key-2024-q1 — and it’s retried as a literal phrase instead of erroring at you.
Every view is a URL
Filters and search live in the address bar. Bookmark a view, send someone a link, step back through your filters with browser back. One view per browser tab — native pinning and Cmd-1..9, no in-app tab strip to learn.
err*
Prefix — matches error, errno, errored
"connection refused"
Exact phrase
error NOT debug
Boolean AND, OR and NOT queries
message:login
Scope to one column
attributes_text:user_id
Search attribute keys, not just values
error_text:timeout
Search inside captured error objects
Live tail
A tail you can stop and interrogate.
Terminal ordering — newest at the bottom, scroll up for history. Scroll up and the tail pauses itself, collecting arrivals into a pill instead of yanking you back down.
Auto-pause, then flush
Arrivals queue up as ↓ N new while you’re reading history. Scroll back down and they flush in; the view re-attaches to the tail.
No gaps, no duplicates
Close your laptop, restart the sink, lose your network — the watermark doesn’t move while you’re stopped, so the next poll backfills exactly what you missed.
Connection state always in view
Connected, reconnecting, offline — derived from the tail itself rather than a separate health ping.
Not virtualized, on purpose
A bounded scrollback buffer keeps native Cmd-F, real text selection and expandable variable-height rows working — the way terminal scrollback does.
- connected
- reconnecting
- offline
Zero infrastructure
One process. One database. Light enough to leave running.
No container to build, no search cluster to babysit, no daemon fleet idling on your laptop. Your logs never leave your machine — it works offline, on a plane, with no account and no API key.
services:
collector:
image: otel/opentelemetry-collector:latest
volumes: ["./collector.yaml:/etc/config.yaml"]
search:
image: search-engine:8.13.0
environment: [discovery.type=single-node, JAVA_OPTS=-Xms2g]
ulimits: { memlock: -1 }
viewer:
image: log-viewer:9.2
depends_on: [search]
ports: ["5601:5601"]
volumes:
search-data:
localsink.db next to it
Details that matter
Built to disappear into your dev loop.
-
01
It can’t take your app down
Every transport swallows network errors and non-2xx responses, times out after three seconds, and never throws or blocks. localsink being closed is a non-event for your app.
-
02
Nothing is suppressed
The console.* transport patches every method — log, error, warn, info, debug, trace — and still calls the original method. Your terminal output is unchanged.
-
03
Structured logs stay structured
Click a row for pretty-printed attributes, a formatted error with its stack, and key/value metadata. trace_id and span_id are first-class indexed columns.
-
04
Levels are yours, not ours
Common severity names across pino, winston, syslog and console are colored consistently, anything else gets a stable palette slot — and your raw label always shows. No "unknown".
-
05
Dark-first, with a real light theme
Terminal-adjacent, monospace, dense and calm. The light theme is a designed counterpart, not an inverted afterthought.
-
06
Typed contract, tested against the wire
The wire schemas are published and shared, so the REST API, the MCP tools and every transport can’t drift. A Vitest harness boots a real server on a random port — no mocks.
Licensing
Source-available, not open core.
No artificial feature limits, no crippled community edition, no enterprise-only fork. Every user gets the whole product — commercial use is what's paid for.
Personal & non-commercial
Personal projects, hobbyists, students, academic work, and contributing to open source.
- The complete product — nothing held back
- Full source available to read and build from
- No account, no limits, no cost
Companies & paid work
Internal tooling at a company, work on a paid product, or a client deliverable — including solo consultants and freelancers.
- Identical product and full source
- Same UI, same API, same MCP tools
- One seat covers every machine that developer uses
Coming soon
localsink isn't released yet.
Follow along on GitHub, or get in touch about commercial licensing.