nova capture, setup and adapter commands
Part of the NovaFabric CLI reference. Both nova and novafabric run the same binary.
Setup (v0.38)
nova init
Initialise a local NovaFabric installation (pip install path only — docker-compose is self-initialising via its entrypoint).
Creates the directory structure under NOVAFABRIC_HOME and generates an Ed25519 signing
keypair for NovaSeal. Safe to run multiple times — existing keys are never overwritten
unless --force is passed.
nova init # uses $NOVAFABRIC_HOME (default ~/.novafabric)
nova init --home /data/nova # custom home
nova init --force # regenerate signing keypairOptions
| Flag | Description |
|---|---|
--home PATH |
Override NOVAFABRIC_HOME for this run |
--force |
Regenerate the Ed25519 keypair even if one already exists |
Created paths
| Path | Purpose |
|---|---|
$NOVAFABRIC_HOME/capsules/ |
Default capsule storage |
$NOVAFABRIC_HOME/keys/signing_key.pem |
Ed25519 private key (mode 600) |
$NOVAFABRIC_HOME/keys/signing_key.pub.pem |
Ed25519 public key |
$NOVAFABRIC_HOME/replays/ |
Replay result storage |
Note: docker-compose users do not need
nova init. The container entrypoint handles first-boot setup automatically.
Capture commands (v0.2)
nova capture <cmd...>
Wrap a command and record its execution as a replayable run capsule.
nova capture python train.py --lr 0.001
nova capture -- python -c "import sys; sys.exit(1)"
nova capture --output-dir /mnt/runs python agent.pyOptions:
--output-dir, -o PATH— base directory for capsule storage (default:$NOVAFABRIC_HOME/capsules/)--timeout FLOAT— wall-clock deadline in seconds for the captured command (default: 600). Increase for long-running agents:nova capture --timeout 3600 python agent.py--runner {local,docker,kubernetes,slurm,lsf,pbs}— execution backend (default:local). Tab-completion available vianova --install-completion.- Every runner except
localforwards onlyNOVAFABRIC_*environment variables into the workload, plusPATHforslurmand whatever you name inextra_envfordocker/kubernetes(ADR-0270).localruns as you, on your machine, and keeps your full environment.
- Every runner except
--environment TEXT— experimental (ADR-0126). Deployment-environment tag recorded verbatim on the capsule as the additive optionaldeployment_environmentfield (with its provenance inenvironment_source): conventionallyproduction|staging|development|test, or any custom string (e.g.prod-eu,canary— a value outside the conventional four warns but is accepted). Precedence: this flag > theNOVAFABRIC_ENVIRONMENTenv var > the SDKdeployment_environment=argument; if none is supplied both fields stay absent (read asunknown) and the manifest is byte-compatible with earlier capsules. Never inferred from host/branch/namespace. Distinct from theenv.locktechnical environment (ADR-0007) — this is a delivery-lifecycle label, not a reproducibility fingerprint. Example:nova capture --environment production -- python agent.py--experiment TEXT,--variant TEXT,--variant-source TEXT(+ optional--variant-label TEXT,--variant-assigned-at RFC3339) — experimental (ADR-0116). Record which A/B experiment and variant an external allocator had active for this run, as the additive optionalvariantblock on the capsule manifest (experiment_id,variant_id,assignment_source, plus optionalvariant_label,assigned_at). Record-only: every field is copied verbatim from what you supply — NovaFabric never assigns, splits, samples, or analyzes variants (that is LaunchDarkly/Statsig/GrowthBook territory, an explicit non-goal), so--variant-source(the external assigner, e.g.launchdarkly,statsig,upstream-router) is required with the other two and is never defaulted, and--variant-assigned-atis never substituted with the capture time. Precedence: these flags > theNOVAFABRIC_VARIANT*env vars > the SDKvariant=mapping argument, resolved atomically per source (no cross-source mixing). If nothing supplies a block it stays absent and the manifest is byte-compatible with earlier capsules. An incomplete flag set fails before capture starts; incomplete ambient env vars warn and are ignored (never block the workload). Example:nova capture --experiment exp1 --variant arm-b --variant-source statsig -- python agent.py--session-id ULID,--session-sequence INT— experimental (ADR-0122). Tag the run as one ordered turn of a multi-turn session (a conversation or workflow of N otherwise-independent runs), recorded as the additive optionalsession_id/sequenceback-reference fields on the capsule manifest.--session-idmust be a ULID (create one withnova session new);--session-sequenceis the zero-based turn index and requires--session-id. Precedence: these flags > theNOVAFABRIC_SESSION_ID/NOVAFABRIC_SESSION_SEQUENCEenv vars > the SDKsession_id=/session_sequence=arguments, resolved atomically per source. Absent = a standalone run, byte-compatible with earlier capsules. Thesession.jsonmanifest (nova session, below) stays the authoritative ordered index; the capsule-side fields are advisory. Not the parent/child distributed-run hierarchy (ADR-0039) — that groups the workers of one job; a session groups separate runs over time. Example:nova capture --session-id 01HZ8S9K3M4YZ2K7N9DPBYK2W0 --session-sequence 2 -- python agent.py--mark-provenance— write a C2PA synthetic-content provenance marker (c2pa-manifest.json, with thec2pa.ai.generated: trueEU AI Act Art.50 disclosure) into the capsule when the run produces model output. The marker is written before NovaSeal so it is covered by the capsule signature (ADR-0074). Opt-in; non-blocking. Example:nova capture --mark-provenance python agent.py--fast-emit— install capture hooks lazily in the workload subprocess (ADR-0092 slice B). The default path imports every present SDK (openai,mcp,requests, …) at startup purely to patch it — measured at ~717 ms foropenai, ~340 ms formcp, paid even if the workload never calls them.--fast-emitpatches each SDK only if/when the workload itself imports it, so unused SDKs are never imported by capture. Measured (warm-fs, orchestrator): a compute-only workload 2068 ms → 464 ms (−78 %); animport openaiworkload 2223 ms → 1509 ms (−32 %) — the saving scales inversely with SDK usage. Fidelity is unchanged. Runs in-process (not delegated to the warm daemon). Example:nova capture --fast-emit python agent.py--emit-spool— experimental (ADR-0092 slice C). Also write run-boundary EventEnvelope v1 records (run.start,capsule.finalize) to the local event spool ($NOVAFABRIC_SPOOL_DIR, default$NOVAFABRIC_HOME/spool) so the residentnovafabric-spool-forwardercan drain and forward them to the collector tier over NATS JetStream. Off by default; fail-open; edge-keyless — signing happens at the hub, not here (hub-sign default). Runs in-process (not delegated to the warm daemon). Example:nova capture --emit-spool python agent.py--emit-otel-genai— experimental (NF-032, ADR-0098). After capture, emit the run outward as OTel GenAIgen_ai.*spans (OTLP-shaped JSON) to<capsule>/otel-genai-spans.json: a rootinvoke_agentspan plus achatclient span per model call and anexecute_toolspan per tool call. Every span carriesnovafabric.mapping_versionand an honestnovafabric.semconv_maturity(stableon LLM client spans,developmenton agent/tool spans — OTel GenAI agent spans are Development-status). Severity projection (experimental, ADR-0127 P4): achat/execute_toolspan whose record carries alog_levelalso getsnovafabric.severity_number+novafabric.severity_text(OTel logsSeverityNumberscale:debug→5/DEBUG,info→9/INFO,warn→13/WARN,error→17/ERROR; vendor-namespaced because OTel defines no span-level severity attribute). No level recorded → neither attribute (never defaulted toINFO). Additive; runs in-process. Example:nova capture --emit-otel-genai python agent.py--capture-content— opt-in (NF-033). With--emit-otel-genai, include request messages in the emitted spans, routed through the ADR-0009 secret-redaction gate and size-bounded (ADR-0021 span cap). Off by default — spans carry no message/choice content unless this is set.--capture-media— experimental, opt-in (ADR-0125). Store the bytes of multimodal message parts on model calls (inline base64 images/audio/documents — Anthropicsource.type: base64blocks, OpenAIimage_urldata-URLs andinput_audio) content-addressed in the capsule blob store atoutputs/<sha256>.<ext>(deduplicated; bounded per part, default 10 MiB,NOVAFABRIC_MEDIA_MAX_BYTESoverride) and list each blob as anArtifactin the sealed manifest. Off by default (ADR-0021 §4 privacy-by-default): the part is always rewritten to amediareference block — IANAmedia_type,sha256:<hex>content_hashover the raw bytes,byte_size— and without this flag the bytes are discarded after hashing (blob_ref: null, reference-only). Inline base64 never lands inmodel-calls.jsonleither way; URL-referenced media is never fetched.nova validatere-hashes every captured blob against its recordedcontent_hash(tamper ⇒ validation fails); read back withnova media list. Example:nova capture --capture-media -- python vision_agent.py--masker NAME— experimental (ADR-0135). Enable a registered PII masker for this capture, bynovafabric.maskersentry-point name or dotted import path (repeatable). Custom maskers run after the built-in ADR-0009 secret scanner — built-ins always run and can never be disabled by a plugin — and every mask is attributed inredaction-proof.json(masker_findings[]). Fail-closed: a crashing, hanging, or invalid masker redacts the field (recorded inmasker_errors[]) and never blocks the workload; an unresolvable masker aborts capture before the workload runs. Example:nova capture --masker novafabric-email python agent.py--masking-config PATH— experimental (ADR-0135). Path to amasking.yamldescribing the custom masking pipeline (masker order, per-maskertimeout_ms,max_input_bytes,on_error: redact|drop, opaqueconfig). Defaults to.novafabric/masking.yamlwhen that file exists. Absent config orenabled: false⇒ capture behaves exactly as ADR-0009 today, byte-for-byte. Schema:schemas/masking-config.schema.json. See the developer guide for writing a masker.
Use -- to separate nova capture options from commands that contain flags:
nova capture -- python script.py -v --config cfg.yamlThe capsule is written to <output-dir>/<ulid>/. On exit (success or failure),
all artifacts are finalized and secret-scanned before the process returns.
Output:
✓ Capsule written: .novafabric/runs/01HXAY7M5JZ8R7K4P9DPBYK2WX (run_id=01HXAY7M5JZ8R7K4P9DPBYK2WX)Exit code mirrors the wrapped command's exit code, with two exceptions that
NovaFabric produces itself: 124 when the workload exceeded its wall-clock
deadline, and 127 when the workload never started (the command does not
exist, is not executable, or the runner could not launch it). A capsule is
written in every case, including both of those — the attempt is evidence.
Because a real program can also exit 127, the code alone cannot tell the two
apart, so the never-started case prints an extra line naming the reason:
✗ Workload never started: [Errno 2] No such file or directory: 'my-agnet'
✗ Capsule written: .novafabric/runs/01HXAY7M5JZ8R7K4P9DPBYK2WX (run_id=01HXAY7M5JZ8R7K4P9DPBYK2WX)Observation log levels (ADR-0127 — experimental). Every record in
model-calls.jsonl / tool-calls.jsonl may carry an additive optional severity
trio: log_level (debug | info | warn | error, lower-case), a secret-scanned
one-line status_message, and a log_level_source provenance
(framework | span-status | adapter | user). The level is a stored forensic
attribute — written once at capture, never acted on (no alerting, paging,
retrying, or blocking). Producers normalize framework names before writing
(WARNING → warn, CRITICAL/FATAL → error, TRACE → debug); an
out-of-domain value is rejected at write, and a record without the fields is
byte-identical to today — old capsules stay valid and read identically, with a
missing log_level read as info by filters (absence is preserved, never
back-filled). The OTLP trace import (server /api/otlp/v1/traces) maps a span
that reported STATUS_CODE_ERROR to log_level: error with
log_level_source: span-status, and (experimental, ADR-0127 P4 inbound) consumes the
OTel SeverityNumber carried by novafabric.severity_number/novafabric.severity_text
span attributes (as written by --emit-otel-genai) or by a span event's
severityNumber/severityText: 1–8 → debug, 9–12 → info, 13–16 → warn, 17–24 →
error, so a level survives an export→import round trip. The most severe of span status
and severity wins (span status keeps a tie); a severity-decided level records
log_level_source: adapter. Out-of-range or non-integer severity is ignored — never
guessed — and stays under otlp.unmapped. Filter recorded levels offline with
nova query --where 'log_level >= warn' (severity-ordered, ADR-0129). Python
capture API: novafabric.capture.log_level (normalize_log_level,
resolve_log_level — most-severe source wins, provenance recorded).
Automatic capture hooks. During nova capture, the following SDK calls are
recorded automatically when the corresponding library is importable:
| SDK | What is captured | File |
|---|---|---|
openai |
chat completion calls | model-calls.jsonl |
anthropic |
messages calls | model-calls.jsonl |
httpx |
requests to URLs in the URL registry (default: OpenAI, Anthropic, Cohere, Together, Mistral, Replicate) | model-calls.jsonl |
requests |
same registry — covers LangChain, LlamaIndex REST adapters, boto3/Bedrock, and any SDK that ships over requests |
model-calls.jsonl |
mcp (Model Context Protocol) |
every ClientSession.call_tool invocation |
tool-calls.jsonl (transport=mcp, full envelope) |
requests/httpx/aiohttp/urllib3 |
every outbound HTTP request (AI or not), as a NetworkEvent |
network_events.jsonl |
| third-party plugins | any class registered under the novafabric.hooks entry-point group |
per the plugin's contract |
If a library isn't installed, the corresponding hook is silently a no-op. No configuration needed.
The capsule also carries event streams written by the in-capture EventRecorder
when the corresponding events occur: network_events.jsonl (HTTP),
file_events.jsonl (file operations), and human_approvals.jsonl (approval
gates). The capsule manifest references each stream (network_events_ref etc.)
only when it is non-empty.
Third-party plugins (experimental, v0.5.x). Any Python package that
declares a class under the novafabric.hooks entry-point group is auto-discovered
at capture time. A failing plugin is isolated and logged — it cannot break the
built-in hooks. See docs/integrations/writing-a-hook-plugin.md
for the contract; the strategic context is RFC-0001.
Customizing the URL registry (v0.5.x). The wire-level hooks (httpx, requests) classify outbound URLs against a YAML registry vendored at src/novafabric/capture/hooks/url_registry.yaml. The registry is extended automatically at call time by OLLAMA_BASE_URL (langchain_ollama) and OLLAMA_HOST (ollama SDK) — non-default Ollama ports are captured without any manual configuration. For other private endpoints or providers, drop a replacement file at ~/.novafabric/url_registry.yaml:
schema_version: "0.1.0"
patterns:
- match: "internal-llm.example.com"
gen_ai_system: "internal-prod"
transport: "http"
- match: "api.openai.com"
gen_ai_system: "openai"
transport: "http"A user-override file replaces the vendored default — copy the entries you still want from the default before editing. To override per-invocation, set NOVAFABRIC_URL_REGISTRY=/abs/path/to/registry.yaml. Per RFC-0001 §"Adoption / migration plan," merge semantics for overrides are deferred to v0.6 with a dedicated ADR.
MCP capture (v0.5). Each call_tool invocation produces a tool-calls.jsonl
record with transport: "mcp" and a complete mcp sub-object:
{
"transport": "mcp",
"tool_name": "read_file",
"mcp": {
"server_name": "filesystem",
"server_version": "1.0.0",
"method": "tools/call",
"request_id": "01KR4...",
"envelope": {"jsonrpc": "2.0", "id": "01KR4...", "method": "tools/call",
"params": {"name": "read_file", "arguments": {...}}},
"response_envelope": {"jsonrpc": "2.0", "id": "01KR4...", "result": {...}}
},
"status": "success",
...
}The hook captures at the ClientSession.call_tool boundary (experimental, v0.5;
tested in test_mcp_proxy.py). For uninstrumented agents (Claude Desktop, Cursor,
Continue, third-party SDKs that do not import the Python mcp package), use
nova mcp-proxy below.
nova session (experimental, ADR-0122)
Group N otherwise-independent runs into one multi-turn session — a
conversation or workflow whose turns are separate nova capture invocations.
A session is a local, content-addressed session.json manifest
($NOVAFABRIC_SESSION_DIR, default $NOVAFABRIC_HOME/sessions/<session_id>/)
that references each member capsule by relative path + sha256 of its
capsule.yaml. It copies no capsule data and never writes a member capsule
(one capsule = one writer). Schema: schemas/session-manifest.schema.json.
Not the parent/child hierarchy (ADR-0032/0039): parent/child groups the WORKER capsules of one distributed job; a session groups N separate runs performed in sequence. The two compose — a session member may itself be a distributed-run PARENT capsule.
# Create a session, capture its turns, group them
SID=$(nova session new --kind conversation)
nova capture --session-id "$SID" --session-sequence 0 -- python agent.py "hello"
nova capture --session-id "$SID" --session-sequence 1 -- python agent.py "and then?"
nova session add "$SID" "$NOVAFABRIC_HOME/capsules/<run-id-turn-0>"
nova session add "$SID" "$NOVAFABRIC_HOME/capsules/<run-id-turn-1>"
nova session list # all sessions: kind, member count, created
nova session reindex # (re)build the fast SQLite session index
nova session show "$SID" # ordered turns + aggregate stats
nova session show "$SID" --json # machine-readable: members + stats
nova session replay "$SID" # replay every turn in order (mocked)
nova session replay "$SID" --mode forensic --json
nova session replay "$SID" --from 1 --to 2 --turn-mode 2=forensic
nova session replay "$SID" --dry-run # plan only: nothing executed or written
# Carry the session + its member capsules to another machine
nova session export "$SID" -o session.zip
nova session verify-bundle session.zip # offline, exit 1 on any problem
nova session import session.zip --session-dir /elsewhere/sessionsSubcommands:
nova session new [--kind conversation|workflow|custom] [--user REF]— create an empty session manifest; prints the newsession_id(a ULID) on stdout, script-friendly.--useris an opaque, redaction-safe actor reference (use a hash or handle, never a raw identifier).nova session add <session_id> <capsule>— append a capsule (directory path, or a run_id under the default capsule directory) as the next ordered member; auto-assignssequence, records the content-addressedcapsule_ref, copiesstarted_at.--role TEXTlabels the member (e.g.user-turn). A finalized session refuses adds unless--reopenis passed. Adding the same run twice is rejected.nova session list [--json] [--rebuild-index]— enumerate sessions, newest first. Experimental (ADR-0122 P3): served from the local SQLite session index (<sessions-root>/.session-index.sqlite) when it is fresh — every indexed manifest's(mtime, size, inode)still matches disk, checked bystatwithout parsing any manifest or touching any capsule directory. A missing, stale, corrupt, or wrong-version index falls back to the directory scan (identical output) and, for stale/corrupt, prints a hint on stderr to rebuild.--rebuild-indexrebuilds first. The index and the scan use the same entry filter (a symlinked session directory is skipped by both), and a listing opens the index read-only — it never creates it.nova session reindex [--json]— experimental (ADR-0122 P3). (Re)build the session index from thesession.jsonmanifests (a corrupt or foreign-version file is discarded and recreated). The index is a rebuildable cache — the manifests stay authoritative;new/add/importrefresh an existing index row write-through but never create an index. WAL journal + busy timeout; safe to run concurrently.nova session export <session_id> -o PATH [--capsule-dir PATH] [--json]— experimental (ADR-0122 P4). Write the session and every member capsule as one ZIP:session.jsonbyte-for-byte,capsules/<run_id>/…, and a digest indexsession-bundle.json(artifacts[]sha256 list +manifest_hash, the same recipe as an Evidence Bundle; per-membercapsule_hashis the Evidence Bundle Merkle root). Deterministic: sorted entries, pinned timestamps/permissions, no wall-clock field — the same session gives byte-identical archives. Refuses an empty session, amissing/tamperedmember, a symlink inside a capsule (re-checked when each file is opened), or a non-ULIDrun_id; writes atomically (no partial file). Unsigned — for signed evidence over the members usenova evidence export.nova session verify-bundle <bundle.zip> [--json]— experimental (ADR-0122 P4). Offline verification: every listed digest recomputed, unlisted files rejected,session.jsondigest and ordering re-checked, each member'scapsule.yamlmatched to itscapsule_refand its Merkle root recomputed. Unsafe archive member names (.., absolute, backslash, drive letter), symlink entries, duplicates, entries expanding more than 100x (when over 1 MiB — a zip-bomb guard), and entry-count / byte ceilings are refused before extraction completes; extraction counts the bytes actually inflated. A non-ULIDsession_idor a malformedcapsule_refis reported as a problem. Exit 1 on any problem.nova session import <bundle.zip>— experimental (ADR-0122 P4). Verify, then place the session at<sessions-root>/<session_id>/with its members undercapsules/, whereshowandreplayresolve them (still digest-gated). Nothing is written unless verification passes; the destination must be a direct child of the sessions root; never overwrites an existing session.nova session show <session_id> [--json] [--capsule-dir PATH]— the ordered turns with per-member integrity (ok/missingwhen the capsule was deleted or moved /tamperedwhencapsule.yamlno longer matches the recorded hash — reported, never fatal, never silently repaired) plus aggregate stats: turns, resolved/missing/tampered counts, summed duration, summedusage_totalstokens, and summed recordednova.costamounts by currency (written at capture, never recomputed).--capsule-diris an extra base searched as<dir>/<run_id>for members whose recorded path moved.nova session replay <session_id> [--mode forensic|mocked|semantic|exact] [--on-divergence stop|continue] [--continue-past-refusal] [--json] [--output-dir PATH] [--capsule-dir PATH] [--from SEQ] [--to SEQ] [--turn-mode SEQ=MODE]... [--dry-run]— experimental, ADR-0123 P1 + P5. Replay every member capsule in ascendingsequenceorder by invoking the existing single-capsule replay engine (the four ADR-0005 modes; defaultmocked) once per turn — no new replay mode, no bypass of the inherited mutating-tool/secret-gate defaults (ADR-0012). Each turn produces its own replay capsule under--output-dir(default./.novafabric/replays), and the session gets oneSessionReplayResultrecord (schemas/session-replay-result.schema.json): ordered per-turn verdicts (reproduced/diverged/refused) plus onewhole_session_verdict. Honesty rules: amissingortamperedmember (same resolution assession show) is a hard per-turn refusal, halting the session unless--continue-past-refusal(the override is logged into the result); a soft divergence (the re-executed turn exited non-zero, or anexact-mode precondition failed → refusal) halts under the default--on-divergence stopand may be continued past with--on-divergence continue; turns after a halt are absent from the record, never markedskipped; a session with sequence gaps, or an empty session, refuses outright. Exit code is 0 only when the whole-session verdict isreproduced. P5 (experimental):--from/--to(inclusive; one bound extends to the session edge) replay one contiguous slice, recorded as the optionalrangefield so the verdict is read as covering a slice;--turn-mode SEQ=MODE(repeatable) pins a per-turn mode (ADR-0123 D6), recorded in each turn'seffective_modeand in the optionalturn_mode_policyfield — a pin for a turn that is absent or outside the slice is refused, never silently ignored;--dry-runprints the plan (turn order, per-turn effective mode, member integrity, and each turn's recorded tool calls classified by the inherited per-capsule policy: mock/allow/deny counts and how many are mutating) and executes and writes nothing (exact-mode preconditions are only checked on execution). Still future design (planned): content-addressed state-seam verification between turns (P2 — so a slice's first turn replays from its captured inputs), the composed session attestation +--attest(P4), and the session-wide cost ceiling.
All commands are local-first (no server, no network for
forensic/mocked replay) and read-only over member capsules.
--session-dir PATH on every subcommand overrides the sessions root.
nova media (experimental, ADR-0125)
Read surface over the content-addressed media recorded on a capsule's
model calls (ADR-0125). When a
model call's messages carried inline media (base64 image/audio/document
blocks), capture rewrites each part to a media reference block — IANA
media_type, sha256:<hex> content_hash over the raw bytes, byte_size,
redacted, and (with nova capture --capture-media) a deduplicated
blob_ref into the capsule's outputs/ blob store. Schema:
schemas/media-part.schema.json.
nova media list <capsule-dir> # table: type, media_type, hash, bytes, blob_ref, redacted
nova media list --json <capsule-dir> # machine-readable, one object per media partReference-only parts (the default — ADR-0021 privacy-by-default) show
— (reference-only) for blob_ref: identity is proven by hash without
holding the bytes. Integrity checking is nova validate's job — it re-hashes
every captured blob against the recorded content_hash and fails on a
missing or tampered blob. Local-first, read-only, no server.
nova api-proxy (v0.6.4)
Transparent HTTP proxy that sits between a non-Python LLM client and an
upstream provider, recording every request/response pair as a
model-calls.jsonl entry. Implements ADR-0026.
Use when the client cannot import Python hooks — Claude Code, Cursor,
Continue.dev, or any Node/Go/Rust agent that uses a *_BASE_URL env var.
# Terminal 1 — start the proxy
nova api-proxy \
--upstream-url https://api.openai.com \
--listen 127.0.0.1:8765 \
--capsule-dir .novafabric/runs/<run-id>
# Terminal 2 — point the client at it
export OPENAI_BASE_URL=http://127.0.0.1:8765
claude ... # or cursor, or any LLM client that respects BASE_URLOptions:
--upstream-url URL(required) — upstream LLM API base URL (e.g.https://api.openai.com)--listen host:port— listener address (default:127.0.0.1:8765)--capsule-dir PATH— active capsule directory; falls back to$NOVAFABRIC_CAPSULE_DIR, then$NOVAFABRIC_HOME/capsules/; auto-allocates a fresh ULID sub-dir--span-id HEX— parent OTel span id (16 hex chars); falls back to$NOVAFABRIC_SPAN_ID--upstream-timeout FLOAT— per-request timeout in seconds (default:60.0)
The proxy performs base-URL-override only — no TLS MITM, no local CA. Both
streaming (text/event-stream) and non-streaming responses are captured.
nova mcp-proxy (experimental, v0.5.x)
Transparent stdio proxy that sits between an MCP client and an upstream MCP
server, recording every tools/call request/response pair into the active
capsule. Implements ADR-0015 §Secondary.
NOVAFABRIC_CAPSULE_DIR=/path/to/.novafabric/runs/01HX...
nova mcp-proxy -- npx -y @modelcontextprotocol/server-filesystem /tmpOr with an explicit flag:
nova mcp-proxy --capsule-dir .novafabric/runs/01HX.../ -- /usr/local/bin/my-mcp-server --flagClaude Desktop integration. Replace the upstream server's entry in
claude_desktop_config.json with a proxy invocation:
{
"mcpServers": {
"filesystem": {
"command": "nova",
"args": [
"mcp-proxy",
"--capsule-dir", "/Users/me/.novafabric/runs/01HX...",
"--",
"npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"
]
}
}
}Records emitted by the proxy use the same tool-calls.jsonl schema as the
client-wrapper hook, plus extensions["io.novafabric.capture_method"] = "proxy"
and extensions["io.novafabric.mcp_protocol_version"] (sniffed from the
upstream's initialize response). Verbatim JSON-RPC envelopes — request and
response — are recorded; the proxy never rewrites bytes.
Status: experimental. Stdio transport only. HTTP/SSE transport,
resources/read, prompts/get, and sampling/createMessage capture are
deferred to v0.6+. Multi-upstream routing and config files are out of scope —
one proxy invocation per upstream server.
Collector buffer operations (experimental, v0.51.0, ADR-0020)
nova collector rebuild
Rebuild the downstream store by replaying the durable JetStream event buffer
from offset 0 — the SPK-COL-1-proven recovery path (byte-equal rebuild,
per-run order preserved, RF1 broker-restart safe). Requires
pip install novafabric[scale] (nats-py).
nova collector rebuild --target ./rebuilt
nova collector rebuild --target ./rebuilt --stream nova-evidence \
--subject "nova.evidence.>" --report rebuild-report.jsonOptions:
--target DIR— directory to materialize per-run JSONL partitions into (required)--nats-url URL— broker (default:$NOVA_NATS_URLornats://localhost:4222)--stream NAME— JetStream stream (default:$NOVA_NATS_STREAMornova-evidence)--subject FILTER— subject filter (default:$NOVA_NATS_SUBJECTornova.evidence.>)--report PATH— write theRebuildReportJSON (per-run sha256 digests, order flags)
Exit codes: 0 — rebuilt, order preserved · 1 — broker unreachable / drain error ·
2 — rebuilt but a per-run seq order violation was detected.
Events without a parseable run_id are routed to the _unattributed
partition, never dropped. See also deploy/collector-arrow/ for the
OTel-Arrow wire profile (31.5 % measured egress reduction).
Warm capture daemon (experimental, ADR-0092, Linux only)
A long-lived per-node daemon that imports novafabric once and serves each run
from a fork, eliminating the per-run orchestrator cold-start at fleet scale
(realizes SI-2; extends ADR-0020).
Strictly opt-in — with no daemon running, nova capture behaves exactly as before.
nova daemon start | stop | status
nova daemon start # foreground; bind $NOVAFABRIC_HOME/run/capture.sock
nova daemon start --max-concurrency 128
nova daemon status # running / not running
nova daemon stop # SIGTERM the running daemonRun nova daemon start under a process supervisor (systemd, a Slurm Prolog, or
a Kubernetes DaemonSet). The socket is created mode 0600 under a 0700 directory,
owned by the agent UID; connections from other UIDs are rejected
(SO_PEERCRED). There is no network listener.
novacap <cmd...>
Stdlib-only thin client (no novafabric import, so ~tens of ms to start). It
forwards the command, cwd, and environment to the daemon and passes your
stdin/stdout/stderr through, so the workload's terminal behaves normally.
novacap python agent.pyIf no daemon is reachable, novacap transparently falls back to
nova capture --no-daemon (it never blocks your workload). For fleet use,
invoke novacap as the per-run entry (e.g. the Slurm srun wrapper) — that is
where the cold-start saving is realized.
nova capture also gains --daemon/--no-daemon (default auto): a plain
capture delegates to the daemon when one is reachable, but any invocation using
--runner, --runner-option, --timeout, --asset, --mark-provenance,
--capture-media, or --output-dir runs in-process so those flags are honored.
Scope honesty: the daemon removes the orchestrator's own import cold-start
(measured: /bin/true capture 593.9 ms → 209.6 ms, −64.7 % warm-fs). A
nova-instrumented Python agent still pays a one-time sitecustomize import
inside its own process to install the wire hooks; reducing that is a later slice.
A capsule produced via the daemon is structurally identical to one from a direct
nova capture.
Phase 3 — Distributed run commands (v0.15.0, ADR-0044/0045/0046)
Commands for parent/child capsule hierarchies (distributed Slurm/K8s runs).
nova run new-run-id
Print a fresh ULID for use as NOVAFABRIC_GLOBAL_RUN_ID. Also available as top-level nova new-run-id.
export NOVAFABRIC_GLOBAL_RUN_ID=$(nova run new-run-id)
nova run new-run-id # prints e.g. 01HXAY7MZPQRSTUVWXYZnova run validate-distributed <capsule-dir>
Validate a distributed (parent/child) run capsule tree.
nova run validate-distributed .novafabric/runs/01HXPARENT/
nova run validate-distributed .novafabric/runs/01HXPARENT/ --parent-run-id 01HXPARENTOptions:
--parent-run-id TEXT— override parent run_id (default: read fromcapsule.json)
Exit codes: 0 = COMPLETE, 1 = FAILED, 2 = PARTIALLY_COMPLETE.
nova run show <capsule-dir>
Show a parent capsule and optionally its children in a tree view.
nova run show .novafabric/runs/01HXPARENT/
nova run show .novafabric/runs/01HXPARENT/ --with-children
nova run show .novafabric/spool/ --run-id 01HXPARENT --with-children --output jsonOptions:
--run-id TEXT— parent run_id (inferred fromcapsule.jsonif omitted)--with-children— render the full child tree--output text|json— output format (default:text)
nova run lineage <run-id>
Query lineage edges for a distributed run.
nova run lineage 01HXPARENT
nova run lineage 01HXPARENT --edge-types contains,spawned --output json
nova run lineage 01HXPARENT --spool-dir .novafabric/spool/Options:
--spool-dir PATH— capsule spool directory (default:.)--edge-types TEXT— comma-separated filter:contains,spawned,delegated_to,replayed_from,member_of_session,wrote_memory,read_memory--output, -o text|json— output format (default:text)
Memory commands (Unreleased)
Memory provenance (ADR-0143 P1). Answers the poisoned-read question: an agent gave a bad answer from something it remembered — where did that value come from, and who else read it?
These commands read memory_operations.jsonl from a capsule. A capsule
without that file is valid; the commands report no operations rather than
failing. No memory values are shown — provenance is by key only, so the
graph never becomes a second copy of the content (ADR-0021 §4).
nova memory lineage <capsule>
List the memory provenance edges implied by a capsule: wrote_memory
(run → item) on each write or update, read_memory (item → run) on each
read. Deletes emit no edge.
nova memory lineage .novafabric/runs/01HXAY7M5JZ8R7K4P9DPBYK2WX/
nova memory lineage .novafabric/runs/01HXAY7M5JZ8R7K4P9DPBYK2WX/ --output jsonOptions:
--output, -o text|json— output format (default:text)
nova memory trace <capsule> --key <memory-key>
Back-trace one memory key: which runs wrote it (oldest first), and which runs read it (the blast radius).
nova memory trace .novafabric/runs/01HXAY7M5JZ8R7K4P9DPBYK2WX/ --key user_prefs
nova memory trace .novafabric/runs/01HXAY7M5JZ8R7K4P9DPBYK2WX/ -k user_prefs -o jsonnova memory trace runs/A/ --key user_prefs --also-capsule runs/B/ --also-capsule runs/C/Options:
--key, -k TEXT— memory key to trace (required)--also-capsule PATH— another capsule to search (repeatable)--output, -o text|json— output format (default:text)
Scope, honestly: a persistent memory store exists so one run can read what another wrote, so a single capsule usually cannot answer this — the runs that read a poisoned key live in their own capsules, not the writer's. Pass every capsule that might have touched the key.
The result always reports how many capsules were searched and how many carried memory operations, in both output formats. That matters: a blast radius over 3 of 50 capsules is not the blast radius, and an answer whose coverage is unstated reads as a complete one. Writers and readers are ordered by event time across capsules, and a run appearing in two of them is counted once.
A capsule path that does not exist is an error, not a skip — silently dropping one would shrink a blast radius without saying so.
A read may carry origin_run_id — what the reading agent believed it was
reading. That is recorded as a claim on the edge, not as the edge's source:
the answer to "who really wrote this" comes from the wrote_memory edges, not
from the reader's own assertion.
nova memstore access ledger | derive | provenance (experimental, ADR-0171)
Experimental (ADR-0171 P2, NF-392 / NF-395 / NF-396). Governance evidence about a
long-lived, shared, at-rest store across runs — distinct from nova memory, which is the
per-run view. Reads facets.memstore_mutation (its records_in_this_run, and the access
block) from an explicit list of capsules; there is no cross-run index. Read-only: nothing
is written. Every output ends with the in-mission-boundary line: NovaFabric records evidence
about the store; it never hosts, serves, manages, or gates it.
# NF-392: who read/wrote which entry, and was it in the agent's declared scope?
nova memstore access ledger --capsule runs/A --capsule runs/B --store org-kb/support [--agent triage-agent] [--uncontained] [--json]
# NF-395: back-trace a read in run C to the store-write that seeded it, then upstream
nova memstore derive --entry bl-7 --run run_C --capsule runs/A --capsule runs/B --capsule runs/C --store org-kb/support [--namespace billing] [--ledger ledger.json] [--max-depth 8] [--max-nodes 500] [--json]
# NF-396: source → store-write → later runs for one entry
nova memstore provenance --entry pb-4471 --capsule runs/A --capsule runs/B --capsule runs/C --store org-kb/support [--namespace playbooks] [--ledger ledger.json] [--json]contained: falseis evidence, never enforcement. It is computed fromallowed_scope(*,<namespace>, or<namespace>/<entry-glob>, comma-separated; the namespace part is never a glob) and re-verified here, so a forged flag makes the command exit 1.- Binding is by content identity: a read binds to the most recent recorded write of the
same entry whose post-op
value_digestequals the digest the read observed (a write provably after the read is excluded). A contradicting reader claim isclaim_mismatch; a read of a value no supplied capsule recorded writing isunresolved— never invented. - Upstream hops (
derive) follow the origin run's reads made before its write: run-level co-occurrence, not proven causation. Cycle-safe and bounded (--max-depth0–64,--max-nodes1–10000); hitting a cap reportstruncated. - A capsule set that omits a run yields a partial mutation chain, reported as
ledger_chain_ok: false(a warning; not repaired).--ledgertakes the sealed sidecar (JSON list or{"ledger": [...]}) instead of re-assembling. - Exit codes:
0reported (out-of-scope rows do not fail);1defective evidence (broken access chain, forgedcontained, malformed block, broken--ledgerchain) or, forderive, a root read that binds to no recorded write;2nothing could be checked (unreadable/oversize capsule or ledger, no read/write of the entry to trace).
The other ADR-0171 surfaces (nova memstore mutation show|verify, retention verify,
poisoning list, stale, conflicts, purpose, snapshot log, nova lineage memstore) are
planned, not shipped.
Framework Adapters (v0.26.0)
Drop-in capture adapters for four additional AI frameworks. Each adapter uses the SDK's own native extensibility interface (ADR-0078) rather than wrapping the executor. All framework packages are optional extras.
OpenAI Agents SDK adapter (E-5)
# Install: pip install 'novafabric[openai-agents]'
from novafabric.adapters.openai_agents import register
# Call once at startup before any agent runs
register()
# All subsequent Runner.run() calls are captured as nova capsules
result = await Runner.run(agent, "hello")The adapter registers a NovaCapsuleTracingProcessor via add_trace_processor().
Each trace produces one capsule in $NOVAFABRIC_HOME/capsules/. capture_mode is
adapter-openai-agents.
Top-level alias: from novafabric.adapters import register_openai_agents
Google ADK adapter (E-6)
# Install: pip install 'novafabric[google-adk]'
from novafabric.adapters.google_adk import make_plugin
from google.adk.runners import Runner
runner = Runner(
agent=my_agent,
session_service=svc,
plugins=[make_plugin()],
)The adapter implements before_run_callback and after_run_callback on a
NovaAdkPlugin instance. capture_mode is adapter-google-adk.
Top-level alias: from novafabric.adapters import make_google_adk_plugin
AWS Bedrock AgentCore adapter (E-7)
# Install: pip install 'novafabric[bedrock-agentcore]'
import boto3
from novafabric.adapters.bedrock_agentcore import wrap_client
client = wrap_client(
boto3.client("bedrock-agent-runtime", region_name="us-east-1")
)
response = client.invoke_agent(
agentId="my-agent", agentAliasId="TSTALIASID",
sessionId="session-1", inputText="hello"
)
for event in response["completion"]:
if "chunk" in event:
print(event["chunk"]["bytes"].decode())The adapter wraps invoke_agent() and parses the EventStream for
orchestrationTrace, preProcessingTrace, postProcessingTrace chunks (written to
bedrock-traces.jsonl). The capsule is finalized when the stream is exhausted.
capture_mode is adapter-bedrock-agentcore.
All non-invoke_agent methods are delegated transparently to the underlying client
(__getattr__ passthrough).
Top-level alias: from novafabric.adapters import wrap_bedrock_agentcore
A2A SDK adapter (E-8)
# Install: pip install 'novafabric[a2a]'
from novafabric.adapters.a2a import make_interceptor
from a2a.client import A2AClient
client = A2AClient(
base_url="http://my-agent:8080",
interceptors=[make_interceptor()],
)
result = await client.send_message(agent_card=card, message=msg)The adapter implements before() and after() on a NovaA2AInterceptor instance.
Only send_message and send_message_streaming calls are captured; other methods
pass through unchanged. capture_mode is adapter-a2a. Task envelopes are written
to a2a-tasks.jsonl inside the capsule.
This implements RFC-0002 §Q4 (full A2A protocol-aware capture), deferred until A2A SDK reached 1.0 stability.
Top-level alias: from novafabric.adapters import make_a2a_interceptor
Reference: src/novafabric/adapters/, design/adr/0078-ecosystem-adapters.md (private).