Local-first log sink · searchable UI and API · agent-ready MCP
Your logs, readable by you and your agents.
One local process collects everything your app emits. 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=1302For 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 oncheckout, 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 self-contained binary and a SQLite file 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 stdoutimport { createClient } from '@localsink/sdk';
const client = createClient({ serviceName: 'api' });
await client.log({
timestamp: Date.now(),
level: 'info',
message: 'hello',
});Step 2 — point the logger you already have at it: one import, one call.serviceName is the only required option; url defaults tohttp://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 phraseerror AND timeoutBooleans, including NOT debugmessage:loginScope to one columnattributes_text:user_idSearch attribute keys, not just valueserror_text:timeoutSearch inside captured error objectsLive 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 binary. One file. Light enough to leave running.
No container to build, no search cluster to babysit, no daemon fleet idling on your laptop. Nothing leaves 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 itDetails 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 writes to stdout. 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 coloured 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 build, 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 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, a paid product, or a client deliverable — including solo consultants. Pricing isn't set yet; get in touch at licensing@localsink.io.
- Identical product and full source
- Per-organization commercial terms
- Same UI, same API, same MCP tools
Coming soon
localsink isn't released yet.
The server, transports and API are built and tested; the UI is in active development against a settled design. Follow along on GitHub, or get in touch about commercial licensing.