seam-probe
seam-probe is a generic NDJSON probe for embedded-runtime seams. It ships zero app-specific knowledge. Everything app-specific is expressed in a small JSON manifest that the user (or you) writes and hands to the binary.
There are two real seams in this skill's world:
| Seam | What it is | Probe mode | | --------------- | ----------------------------------------------------- | --------------- | | FFI dylib | dlopen + extern "C" entry points + callback struct | seam-probe ffi | | UDS endpoint | Unix-domain socket carrying length-prefixed frames | seam-probe socket |
Plus two utility modes that need no manifest:
| Utility | What it does | | ---------------- | ----------------------------------------------------- | | inspect | Dump exported symbols of a dylib (Mach-O / ELF / PE) | | vocab | Print the stdin/stdout NDJSON contract verbatim |
Scope: what counts as a "seam"
The probe handles dynamically-loaded native code (FFI mode) and Unix-domain sockets carrying byte frames (socket mode). Concretely in scope:
.dylib(Mach-O),.so(ELF),.dll(PE) loaded viadlopen/
LoadLibrary. The library must export an extern "C" lifecycle function and accept a plain C-ABI callback struct.
- Unix-domain stream sockets with
be32/be64/varintframing,
or no framing at all.
Out of scope today (would need probe extensions, not just a manifest):
- Statically-linked Rust crates (
crate-type = ["staticlib"],
produces .a/.lib). Static archives have no dynamic load surface — they're meant to be linked at build time. The probe cannot load them. Tell the user to expose a cdylib alongside their static target for probing; see "Preparing a Rust crate for probing" below. This is a build-config change only — no code changes, no impact on production distribution.
- UniFFI crates. UniFFI produces a cdylib that is dlopen-able,
but exports use UniFFI's own wire format: every call goes through RustBuffer (a (ptr, len, capacity) struct) with a RustCallStatus out-parameter, and arguments are serialised via UniFFI's binary format. The probe's three callback signature kinds (json / json_with_sid / raw_with_seq) don't model this. For UniFFI-exposed crates use uniffi-bindgen to generate a Swift / Kotlin / Python / Ruby binding and test through that. Probing UniFFI directly would require the probe to read the *.uniffi.json metadata and implement the FFI converter protocol — significant scope creep.
- TCP sockets — same framing logic would apply, but the binary
has no --tcp host:port mode yet. For loopback testing of a TCP service, fall back to nc / socat / websocat.
- Subprocess + stdio — driving a CLI that reads NDJSON on stdin
and emits responses on stdout. Common for "headless mode" Rust runtimes. For this, just use shell pipes + tee.
- Wasm modules —
wasmtime/wasmercalling exports with
import shims. Different ABI from C; not implemented.
- gRPC, D-Bus, HTTP, WebSocket — use
grpcurl,busctl,curl,
websocat respectively.
- Kernel modules, JNI, Python C API — out of scope.
If the user wants to probe one of the out-of-scope surfaces, say so and suggest the right tool rather than forcing a fit.
Preparing a Rust crate for probing
If the user's crate is staticlib-only (or rlib-only, or it's a binary crate with no library target), it's not loadable by the probe. Have them add a cdylib output to their Cargo.toml. This does not change what they ship — they can keep their existing target and add cdylib alongside:
[lib]
crate-type = ["cdylib", "staticlib"] # or just ["cdylib"] for probing only
After cargo build --release, the cdylib lands at:
| Platform | Path | | --- | --- | | macOS | target/release/lib<crate_name>.dylib | | Linux | target/release/lib<crate_name>.so | | Windows| target/release/<crate_name>.dll |
The crate must also actually expose its surface as extern "C":
#[unsafe(no_mangle)]
pub extern "C" fn foo_start(cb: *const FooCallbacks) -> i32 { /* … */ }
#[unsafe(no_mangle)]
pub extern "C" fn foo_stop() { /* … */ }
If the crate uses mangle or extern "Rust", it's not probe-able even as a cdylib — symbols won't be resolvable by name. Internal Rust APIs need to be wrapped with a thin C-ABI shim module. This is a small one-time cost; the user can gate it behind a feature flag (#[cfg(feature = "probe")]) so it doesn't pollute production.
Generating a C header at build time with cbindgen is strongly recommended — the manifest's callback_struct[] field order has to match the C struct exactly, and the header is the source of truth.
Preflight
seam-probe vocab >/dev/null
If this errors with seam-probe is not built, the SessionStart hook either hasn't finished yet (fresh install or plugin update — wait a moment and retry) or it failed. Tell the user to run /seam-probe-setup for verbose recovery output.
If the command runs but a flag in this skill is missing or behaviour disagrees with the docs, stop and ask the user to update the plugin. Do not guess around missing features.
When to reach for which mode
- You don't know what the dylib exports → start with
seam-probe inspect. - You have an unfamiliar dylib but know its callback contract → write
a manifest, then seam-probe ffi.
- You have a UDS endpoint but don't know the framing → try
--framing be32 first (most common); fall back to --framing none and inspect raw bytes if framing is wrong.
- You forget the NDJSON contract →
seam-probe vocab. - You're hunting a bug, panic, or hang → run the probe with stderr
captured (FFI) or with the SUT log tailed (socket) and correlate the two streams. See "Read both streams" below.
How to invoke the CLI
The probe is one binary with four subcommands. Always separate stdout (NDJSON) from stderr (probe diagnostics + anything the loaded library prints). The two streams answer different questions and you usually need both.
inspect — list a dylib's exports
seam-probe inspect --lib /path/to/libfoo.dylib
No manifest, no stdin. Emits one symbol line per export plus a summary line. Use it before writing a manifest, or to confirm a build actually exposes what you expect.
ffi — drive a dylib via a manifest
seam-probe ffi \
--lib /path/to/libfoo.dylib \
--manifest /path/to/foo.manifest.json \
[--no-events] \
[--shutdown-grace-ms 2000]
| Flag | When to use it | | --- | --- | | --lib | Required. Shared library to dlopen. | | --manifest | Required. JSON describing the FFI surface (references/manifest-schema.md). | | --no-events | Mute callback event/json_with_sid/raw_with_seq lines. Use when you only care about send/call return codes. | | --shutdown-grace-ms | Time to wait between stop and process exit. Bump it (e.g. 5000) if the runtime has worker threads that need longer to drain. Symptom of "too short": probe hangs at exit, or the runtime later complains about lost messages. |
Read input from stdin (one JSON op per line). Heredoc is the cleanest way to drive a deterministic sequence:
seam-probe ffi --lib ./libfoo.dylib --manifest /tmp/foo.manifest.json \
> /tmp/probe.ndjson 2> /tmp/sut.stderr <<'EOF'
{"op":"call","name":"start_session","arg":"sess-1"}
{"op":"send","lane":"events","payload":{"hello":"world"}}
{"op":"sleep_ms","ms":500}
{"op":"stop"}
EOF
echo "exit=$?"
socket — drive a UDS endpoint
seam-probe socket \
--path /tmp/foo.sock \
[--framing be32|be64|varint|none] \
[--no-events]
| Flag | When to use it | | --- | --- | | --path | Required. The Unix socket the SU