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 keypair

Options

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

Options:

Use -- to separate nova capture options from commands that contain flags:

nova capture -- python script.py -v --config cfg.yaml

The 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/sessions

Subcommands:

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 part

Reference-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_URL

Options:

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 /tmp

Or with an explicit flag:

nova mcp-proxy --capsule-dir .novafabric/runs/01HX.../ -- /usr/local/bin/my-mcp-server --flag

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

Options:

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 daemon

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

If 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. 01HXAY7MZPQRSTUVWXYZ

nova 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 01HXPARENT

Options:

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 json

Options:

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:


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 json

Options:

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 json
nova memory trace runs/A/ --key user_prefs --also-capsule runs/B/ --also-capsule runs/C/

Options:

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]

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