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.

localsinklocalhost:3000/?service=api-gateway,checkout,worker,postgres&level=error,warn
  • Service
  • all
  • api-gateway
  • checkout
  • worker
  • postgres
  • Severity
  • all
  • error
  • warn
  • info
  • debug
live tailtailing
service: api-gateway, checkout +2error, warn
errorapi-gatewayupstream timeout after 30000msroute /v1/checkoutstatus 504+3
warnapi-gatewayretry budget 82% consumed for checkoutattempt 3+2
errorcheckoutpayment intent rejected: card_declinedintent pi_3Qk…amount $42.00+4
warnworkerqueue depth above thresholddepth 1,284queue emails+2
errorpostgresdeadlock detected on relation orderspid 4471+3
warnapi-gatewayslow request exceeded p99 budgetdur 1.42sroute /v1/cart+3
errorworkerjob failed after 5 attempts, moving to dead letterjob reconcile:8812+2
warncheckoutidempotency key reused within 60s windowkey ck_9f2…+4
errorapi-gatewayupstream timeout after 30000msroute /v1/checkoutstatus 504+3
warnapi-gatewayretry budget 82% consumed for checkoutattempt 3+2
errorcheckoutpayment intent rejected: card_declinedintent pi_3Qk…amount $42.00+4
warnworkerqueue depth above thresholddepth 1,284queue emails+2
errorpostgresdeadlock detected on relation orderspid 4471+3

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.

Three tabs of scrollback
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
One filtered view
errorcheckoutpayment intent rejected: card_declined
errorapi-gatewayupstream timeout after 30000ms
errorpostgresdeadlock detected on relation orders

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.

Agent sessionlocalsink MCP
Checkout started failing after my last change. What's going on?
search_logs3 rows
{ q: "checkout", level: ["error"], from: 1785076260000 }
errorcheckoutpayment intent rejected: card_declinedsvc checkout
errorapi-gatewayupstream timeout after 30000mssvc api-gateway
errorpostgresdeadlock detected on relation orderssvc postgres
All three carry 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?
describe_logs

What services, levels and loggers exist, how many logs, what time range.

search_logs

The full filter and full-text query surface, same as the UI.

get_log_by_id

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.

Step 1 — run the sink
$npx localsink

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' },
  }),
);

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.

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.

Scrolled up — reading historyarrivals collecting
warnworkerqueue depth above threshold
infoapi-gatewayPOST /v1/checkout 202
errorcheckoutpayment intent rejected: card_declined
warncheckoutidempotency key reused within 60s window
debugpostgresacquired advisory lock 4471
↓ 12 new
  • 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.

What this usually costs you
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:
What localsink costs you
$ npx localsink
one process, started instantly
one localsink.db next to it
UI, REST API and MCP on one port

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 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.

Free forever

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
Commercial

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.