Home/API
Read a file, call a syscall, or hit the network — the query semantics, and the results, are identical. On the web tier REST, gRPC and MCP all pass through one App::dispatch, so a Search over gRPC returns exactly what /search does.
ls, cat and grep just work), talk to it like a device built into the system, or reach it over the network — including MCP, the protocol AI assistants use to call tools. Pick whichever fits the tool you already have.Mount the repo passthrough (hot reads ~0 overhead) with a virtual /.syna/** namespace. cat, ls, grep, getfattr just work.
$ syna mount /mnt/repo
$ ls /mnt/repo/.syna/\
symbol/validate_token/callers/dev/synafs (CUSE) with a write-query / read-NDJSON model, plus libsyna.so for any FFI caller. io_uring batch.
echo '{"text":"parse config"}' \
> /dev/synafs
cat /dev/synafs # NDJSON hitsREST + WebSocket, gRPC-Web and native HTTP/2 (hand-rolled HPACK), MCP for agents. One App::dispatch; TLS/mTLS opt-in.
$ syna serve --addr :5200 # REST $ syna grpc --addr :5201 # HTTP/2 $ syna-mcp . # agents
A pure-Rust fuser mount (no libfuse — default-features=false, mounted via fusermount3) lays a passthrough over the backing repo: everything outside /.syna/** is byte-for-byte the underlying filesystem, and hot reads are forwarded with near-zero overhead (FUSE passthrough / FOPEN_PASSTHROUGH for direct kernel I/O). Only the /.syna prefix is intercepted; SynaFS logic lives in a pure, unit-tested resolver.
The virtual namespace turns queries into directories you can list, read, and pipe through ordinary tools:
/.syna/query/<q>/# synafs hit view with path / lines / score headers then the span./.syna/symbol/<name>/{def,refs,callers,callees}/.syna/similar/<path>/.syna/history/<path>/<rev>/.syna/ctlcommit, reindex, sync) to trigger engine actions.Results are lazy: nothing runs until readdir, and they're cached by (query, generation) on a short TTL so repeated stat storms are cheap. Each backing file also carries virtual xattrs — user.syna.embedding (base64 vector), user.syna.symbols (JSON), user.syna.summary (optional LLM summary) and user.syna.generation — readable with getfattr -d and read-only.
$ syna mount /mnt/repo # a query is just a directory; ls runs it lazily $ ls /mnt/repo/.syna/query/parse%20config%3F--lang%3Drust/ 000_src_config_rs_L12.view 001_src_cli_rs_L88.view ... $ cat /mnt/repo/.syna/query/parse%20config%3F--lang%3Drust/000_src_config_rs_L12.view # synafs hit · path: src/config.rs · lines: 12-40 · score: 0.031 $ getfattr -d -m user.syna /mnt/repo/src/config.rs
New syscalls would need a kernel patch, so the engine is exposed as a /dev/synafs CUSE character device (userspace char device — the same wire protocol as FUSE, implemented directly over /dev/cuse, no libfuse). Variable-length JSON moves over write/read instead of the ioctl-retry dance: write(query json) runs the query, read drains the results as NDJSON (one hit per line + a trailing summary line).
Three ioctls, numbered _IOWR('S', nr, …), switch a fd's mode: SYNA_QUERY (1), SYNA_OPEN_SEMANTIC (2, route the next read to the last query's best match), SYNA_SUBSCRIBE (3, turn the fd into a reindex-event NDJSON long-poll, same meaning as the web /events).
For any C / C++ / FFI caller there is libsyna.so with a small, stable JSON-in / JSON-out C ABI (struct marshalling avoided on purpose):
// libsyna.h — JSON in / JSON out typedef void *syna_handle; syna_handle syna_open(const char *repo_path); // NULL on error int syna_query(syna_handle h, const char *json, char **out); // 0=OK, *out=malloc'd JSON int syna_query_batch(syna_handle h, const char *json_arr, char **out); // N queries, one crossing void syna_free(char *s); // free *out void syna_close(syna_handle h);
syna_query_batch runs N queries in a single boundary crossing (submit-N / complete-N), isolating per-slot failures as {"error":code}. The kernel-side counterpart is the --features io_uring batch: write_read_batch chains each item's write(query)→read(result) with IOSQE_IO_LINK so a whole batch is submitted and reaped in one io_uring cycle — pure-Rust io-uring (raw io_uring_setup/enter, no liburing). Device creation needs CAP_SYS_ADMIN, but the protocol state machine, dispatch and NDJSON codec are pure functions and are unit-tested without a device.
MCP is the agent front door: syna-mcp speaks line-delimited JSON-RPC 2.0 over stdio with tools search, read_span, neighbors, symbol_lookup, impact, path, diff_impact, context_pack, file_info, apply_edit, subscribe, and diff_symbol. Register it with Claude Code or any MCP client (command = syna-mcp, args = [repo]).
REST (tiny_http, synchronous, loopback bind + Bearer token) exposes the same engine over HTTP/JSON:
GET /healthzgeneration (the only route that skips auth).POST /search · /read_span · /symbol · /neighborsconsistency:strong + ryw_token.POST /apply_edit · /commit · /diff · /historyGET /events · /wsgRPC comes two ways: gRPC-Web (application/grpc-web+proto) over plain HTTP/1.1, and native HTTP/2 (h2c) with a hand-rolled HPACK (Huffman included) — both with no async runtime and no protoc. Search, ApplyEdit, Commit and Health route through REST's same App::dispatch (protobuf → Value → dispatch → Value → protobuf), so a gRPC Search hit equals the REST /search hit. TLS/mTLS is opt-in via rustls (client certs without a chain to the CA are rejected at the handshake, before dispatch). Ports: 5200 (web) / 5201 (gRPC).
Every tool is designed around one question: how many turns does the agent need? A grep loop answers a structural question by reading one file per hop and burning a turn each time. These resolve the same question against the index — the call graph, the version DAG, the vector index — and return the whole answer in a single call.
| Tool | What it answers | Instead of |
|---|---|---|
search | “Where is X handled?” — hybrid vector + BM25, RRF-fused. The top hit carries its full source inline, so the answer usually needs no follow-up read. Pass texts: [...] to answer several independent questions in one call. | a grep loop that opens one file per guess |
impactnew | “What breaks if I change this?” — the transitive closure of callers, grouped by hop distance. The blast radius before you touch a shared helper. direction=callees gives the dependency set instead. | one grep + read per node, per hop |
pathnew | “How does A reach B?” — the shortest chain(s) of direct calls between two functions, every step citing its definition site. If nothing is found, the call may flow the other way. | hand-running a directed grep hop by hop |
context_packnew | “Explain this function.” — its full body, the bodies of what it directly calls (how it works), and its callers as one-line references (who depends on it). budget_bytes caps the payload. | a read_span + grep sequence per helper |
diff_impactnew | “What did this change touch, and which tests should run?” — the entity-level diff between two versions (default: uncommitted work), every symbol transitively affected via callers, and which of those are tests. | git diff → read files to find enclosing functions → grep callers per symbol |
symbol_lookup | Resolve a symbol by name: def, refs/callers, callees, importers. Edges resolve same-file-first, so a homonym in another module doesn't hijack the answer. | grepping for call sites and filtering by eye |
read_span | Read an exact span by chunk_id, or by path with a line range. If a search already inlined that span, the response says so instead of paying for it twice. | cat + manual line counting |
neighbors | Chunks semantically nearest a given path or chunk — “what else looks like this?” | guessing at filenames |
apply_edit | Write a file (full content or byte-range splices) and enqueue a transactional reindex. Returns a token; pass it as a later search's ryw_token to read your own write. | write, then hope the index caught up |
diff_symbol | Diff a single symbol's source between two revspecs (HEAD~3, a version hex, working) — the function's history, not the file's. | git log -p on the whole file |
file_infonew | Is this file vector-indexed, and if not, why — binary, oversize, or untracked. Tells an asset apart from source before you trust a search miss. | assuming a search miss means the code isn't there |
subscribe | Reindex events as they happen — long-poll or WebSocket, for an agent that wants to react to the tree changing under it. | polling the filesystem |
apply_edit — still never searches a stale index. SYNA_FRESH=0 opts out and SYNA_FRESH_MAX caps the sweep (default 64); as_of history queries never reconcile, since the past does not drift.Because every surface shares one write path, a write from any of them — a FUSE passthrough write, apply_edit over REST/MCP, or an external editor — triggers reindex. syna watch closes the loop with two kernel fsnotify backends: inotify (the default, unprivileged, runs in sandboxes/CI), and fanotify (--fanotify, raw fanotify_init/fanotify_mark syscalls, FAN_MARK_MOUNT to cover a whole mount, needs CAP_SYS_ADMIN). A per-path content-hash guard stops the reindex write-back from echoing, and .syna/dotfiles are skipped.
App::dispatch; FUSE, the device, and libsyna run the same syna-query model. A query means the same thing — and returns the same hits — no matter which surface you reach it through.