# Conduit documentation (full) > Secure, self-hosted observability for AI agents: traces, logs and metrics over mTLS, stored in ClickHouse and VictoriaMetrics, with a server-rendered UI. Go services, Go and Python SDKs. --- # Conduit > Secure, self-hosted observability for AI agents: traces, logs and metrics over mTLS, stored in ClickHouse and VictoriaMetrics, with a server-rendered UI. Go services, Go and Python SDKs. Conduit collects traces from AI agents. Each trace is a run broken into spans for planning, tool calls and LLM calls, with status, errors, token counts and cost. Conduit stores the traces and shows them in a fast, server-rendered UI. It's designed for teams that want agent telemetry inside their own infrastructure, with a small and auditable attack surface. ## Highlights - **Identity you can trust.** Agents authenticate with mTLS client certificates, and the certificate's CommonName is the agent's identity. An agent can't spoof it with an attribute. - **Hardened ingestion.** Every span is validated before it reaches storage. Rate limits apply per agent and per telemetry item, so batching can't get around them. - **LLM-aware traces.** Model, tokens, cost and errors are recorded per span and rolled up per trace. Prompts and completions are stored separately from span metadata. - **A UI that's quick to read.** One row per trace, filtering by time, agent and errors, stable paging, and a span tree with a waterfall timeline. - **Small and auditable.** Each service is a static Go binary in a `FROM scratch` image. The UI uses no JavaScript framework, has a strict Content-Security-Policy, and embeds its own assets. - **Production basics included.** UI sign-in with a login form or Google Cloud IAP, health and readiness endpoints, Kubernetes manifests, and a CI pipeline that runs the whole stack end to end. ## How it works ```mermaid flowchart LR A["Agent
Go or Python SDK"] -- "gRPC + mTLS :4317" --> G["Gateway
auth · validation · rate limits"] G -- "JetStream (work queue)" --> N[("NATS")] N --> C["Collector"] C --> CH[("ClickHouse
spans, LLM data")] C -. "best effort" .-> VM[("VictoriaMetrics
span durations")] C -. "OTLP/HTTP, best effort" .-> T[("Tempo
traces")] CH --> U["UI
login or GCP IAP"] ``` The gateway accepts spans only from agents holding a certificate signed by your CA. The collector writes each span to ClickHouse, the system of record, and redelivers it if that write fails. It also copies spans to VictoriaMetrics and Tempo on a best-effort basis. The UI reads from ClickHouse. See [Architecture](architecture.md) for details. ## Quick start You need Docker (Compose v2, 2 GiB of memory). One command runs the whole platform from the signed release images: ```bash git clone https://github.com/tjscanlan/conduit && cd conduit make quickstart # or: ./deployments/quickstart/quickstart.sh ``` It generates a dev CA, agent certificates, and a UI login, waits until everything is healthy, and prints your login. Open : a simulated `demo-agent` is already sending traces, and a Get started page walks you through sending your own agent's first trace. The banner also shows how to send a trace from your own code as `my-agent`; CI checks that a cold start reaches the first trace in under five minutes. Releases also ship [`conduitctl`](cli.md), the same quickstart as a single binary with no checkout needed: `conduitctl quickstart`, plus `agent-cert` and `doctor`. `quickstart.sh status` shows the login again, `down` stops the stack (keeping data), and `reset` deletes everything. Next, run one of the [examples](examples.md) (hello-trace, tool calling, asyncio, Claude, OpenAI) or instrument your own agent with the [Go SDK](sdk-go.md) or [Python SDK](sdk-python.md). To work on Conduit itself (building images from source), see [Deployment](deployment.md#local-docker-compose-from-source). ## Documentation **Read the docs at **, including the [visual roadmap](https://tjscanlan.github.io/conduit/roadmap.html). The same pages are in [`docs/`](). **For AI agents:** - [`llms.txt`](https://tjscanlan.github.io/conduit/llms.txt) indexes the docs and links each page's raw Markdown. - [`llms-full.txt`](https://tjscanlan.github.io/conduit/llms-full.txt) has all the docs in one file. - [`AGENTS.md`](AGENTS.md) tells coding agents how to build, test and change this repo. | Guide | What's in it | |-------|--------------| | [Architecture](architecture.md) | Components, data flow, delivery guarantees and storage schema | | [Security](security.md) | The threat model, mTLS identity, rate limiting, UI authentication and browser hardening | | [Deployment](deployment.md) | Docker Compose, Kubernetes, health checks, upgrades and configuration reference | | [Go SDK](sdk-go.md) · [Python SDK](sdk-python.md) | Instrumenting agents | | [Roadmap](roadmap.md) | What's shipped, what's next and the open decisions | | [Releasing](releasing.md) | Versioning policy, verifying signed images, upgrading and cutting a release | | [Changelog](CHANGELOG.md) | What changed in each release | | [Contributing](contributing.md) | Development setup, tests, code generation and pull requests | | [Brand](brand.md) | Logo, palette and accessibility rules for the UI | ## Project status Conduit is **pre-1.0**. The core pipeline is tested end to end in CI on every push: SDK → gateway → NATS → collector → ClickHouse, VictoriaMetrics, Tempo → UI. APIs and the storage schema may still change between releases. See the [roadmap](roadmap.md). ## License [Apache License 2.0](LICENSE) --- # Architecture Conduit is three Go services (gateway, collector and UI) and two SDKs (Go and Python), built on NATS JetStream, ClickHouse, VictoriaMetrics and Grafana Tempo. This document describes how data moves through the system, what each component guarantees, and how the data is stored. ## Components | Component | Source | Role | |-----------|--------|------| | **SDKs** | `sdk/go`, `sdk/python` | Create spans in the agent process and send them to the gateway over gRPC with mTLS | | **Gateway** | `cmd/gateway` | Authenticates agents, validates spans, rate-limits each agent and publishes to NATS | | **Collector** | `cmd/collector` | Consumes from NATS and writes to ClickHouse (required), VictoriaMetrics and Tempo (both best-effort) | | **UI** | `cmd/ui` | Server-rendered trace list and trace detail, read from ClickHouse, with sign-in | | NATS JetStream | | A durable buffer between ingestion and storage. Spans, logs and metrics use separate work-queue streams (`SPANS`, `LOGS`, `METRICS`), so a backlog in one never blocks the others. | | ClickHouse | | The system of record for spans, LLM data and trace rollups | | VictoriaMetrics | | Agent metrics (the system of record for them) and span duration metrics | | Tempo | | A copy of each trace, in OTLP format, for Grafana | ## Data flow ```mermaid sequenceDiagram autonumber participant SDK as Agent SDK participant GW as Gateway participant N as NATS (SPANS) participant C as Collector participant CH as ClickHouse participant VM as VictoriaMetrics participant T as Tempo SDK->>GW: IngestSpan (gRPC, mTLS client cert) Note over GW: auth → rate limit → validate GW->>N: publish span (JSON) GW-->>SDK: accepted / rejection reason N->>C: deliver (durable consumer) C->>CH: insert (batch API, nanosecond timestamps) alt ClickHouse insert fails C-->>N: Nak, redeliver in 5s else stored C->>VM: span duration (best effort) C->>T: OTLP/HTTP span (best effort, OTLP-shaped IDs only) C-->>N: Ack (deleted from the work queue) end ``` 1. **SDK.** Spans are created in the agent process. Trace and span IDs are sized to the W3C and OTLP standards: 16-byte trace IDs and 8-byte span IDs. Context propagates through `context.Context` in Go and through `contextvars` in Python, so nesting is correct across goroutines, threads and asyncio tasks. Sending is synchronous, one RPC per span. 2. **Gateway.** Handles each request in this order: - **Authenticate** the TLS 1.3 client certificate against the configured CA. The agent's identity is the certificate's CommonName. - **Rate-limit** with a token bucket per identity, charged per telemetry item. - **Validate** the span, including ID charset and size, name, status, timestamps, attribute limits and LLM field bounds. - **Publish** to the `SPANS` stream. The wire API is Conduit's own protobuf `IngestionService` (`proto/ingestion.proto`), not OTLP. 3. **NATS JetStream.** A work queue: each span is deleted once the collector acknowledges it, and the stream has a 7-day maximum age as a backstop. 4. **Collector.** At-least-once delivery into ClickHouse. A failed insert is retried after a 5-second delay, and a message that can't be decoded is discarded for good. Writes to VictoriaMetrics and Tempo are best-effort: their failures are logged, not retried, so they can never duplicate rows in ClickHouse. 5. **UI.** Reads only from ClickHouse: - **Trace list:** the queries are always bounded by a time window, and paging uses a keyset cursor. - **Trace detail:** capped at 2000 spans per trace. - **Sign-in:** a login form with signed session cookies, or Google Cloud IAP. ## Delivery and failure behavior | Failure | Effect | |---------|--------| | The gateway rejects a span (invalid, over the rate limit, unauthenticated) | The SDK gets an error. `Span.End` / `span.end()` raises or returns it, with the reason. | | NATS is unavailable | The gateway fails the publish and returns an error to the SDK. Its `/readyz` reports `nats: unavailable`. | | ClickHouse is unavailable | The collector naks, NATS keeps the span, and it's redelivered every 5 seconds until ClickHouse returns, for up to 7 days. | | VictoriaMetrics or Tempo is unavailable | Logged. The span is still stored in ClickHouse. | | A collector instance restarts | The durable consumer resumes, and unacknowledged spans are redelivered. | The SDKs don't retry. A failed send is reported to the caller, who decides what to do. ## Storage schema ### Schema management The schema is defined by numbered migrations in `internal/storage/migrations/`, which are embedded in the binaries. - **Who applies them:** the collector, at startup, before it reconciles retention. `collector migrate` does the same and exits, for running as a Job before a rollout. The UI never changes the schema. - **Bookkeeping:** applied versions are recorded in `schema_migrations` with a SHA-256 checksum. If a migration is edited after it was applied, or the database has a version the binary doesn't know, the collector refuses to start. - **Idempotency:** ClickHouse has no DDL transactions or locks, so every migration must be idempotent (`IF NOT EXISTS` / `IF EXISTS`), and a test enforces it. That makes it safe for two collectors to start at once. - **Existing installs:** these adopt migrations automatically. Migration 0001 is the original DDL, so it applies as a no-op and is recorded. ### ClickHouse `spans` | Column | Type | Notes | |--------|------|-------| | `trace_id`, `span_id`, `parent_id` | `String` | `parent_id` is empty for root spans | | `agent_name` | `String` | Always the mTLS CommonName, never a client-supplied value | | `span_name` | `String` | | | `start_time`, `end_time` | `DateTime64(9, 'UTC')` | Nanosecond precision, written with the native batch protocol | | `duration_ms` | `UInt64` | | | `status` | `Enum8('ok', 'error', 'unset')` | | | `attributes` | `Map(String, String)` | Includes `error.message` for failed spans | | `llm_model`, `llm_prompt`, `llm_completion` | `Nullable(String)` | Set only on LLM spans | | `llm_input_tokens`, `llm_output_tokens` | `Nullable(UInt32)` | | | `llm_cost_usd` | `Nullable(Float64)` | | | `ingested_at` | `DateTime` | Defaults to `now()` | The table is a `MergeTree`, partitioned by day and ordered by `(agent_name, trace_id, start_time)`. Rows expire after **`SPAN_RETENTION_DAYS`** (default 90, range 1–3650). - **Who sets it:** the collector owns retention, as part of owning the schema. On startup it compares the table's TTL with the setting and runs `ALTER TABLE spans MODIFY TTL …` only if they differ. Shortening retention lets ClickHouse delete rows that are now past it. - **The UI** only creates the tables if they're missing, and never alters them. ### ClickHouse `logs` Log records sent in `IngestBatch`. Each is validated at the gateway: the severity enum, a body of 1 byte to 32 KiB, a timestamp no more than an hour in the future, well-formed optional `trace_id` and `span_id` (a span needs a trace), and the shared attribute limits. Agent identity comes from the certificate, as for spans. | Column | Type | Notes | |--------|------|-------| | `timestamp` | `DateTime64(9, 'UTC')` | Defaults to the receive time if the client sends none | | `agent_name` | `String` | The mTLS CommonName | | `severity` | `Enum8('unspecified', 'debug', 'info', 'warn', 'error')` | | | `body` | `String` | | | `attributes` | `Map(String, String)` | | | `trace_id`, `span_id` | `String` | Empty unless the log belongs to a trace or span | The table is partitioned by day and ordered by `(agent_name, timestamp)`. `SPAN_RETENTION_DAYS` applies to it as well. ### VictoriaMetrics: agent metrics Gauges and counters sent in `IngestBatch` are written to VictoriaMetrics. The gateway validates them: - **Names** must be valid Prometheus names. `conduit_*` is reserved for Conduit's own series, so agents can't forge them, and `__*` is reserved too. - **Labels:** at most 32, with Prometheus-style keys and values of at most 1 KiB. - **Values** must be finite, and counters must be ≥ 0. - **The `agent` label** is always set from the certificate. For metrics, VictoriaMetrics is the system of record, so a failed write is retried, unlike the best-effort span-duration export. Label values are escaped for the Prometheus text format (backslash, quote and newline), so a value can't inject extra samples. ### ClickHouse `trace_summary` A materialized view (`AggregatingMergeTree`) with per-trace rollups: start, end, span count, error count, tokens and cost. It uses `SimpleAggregateFunction` columns, so rows from separate inserts merge correctly. Query it with the matching aggregate functions and `GROUP BY trace_id`. Nothing in the UI reads it yet. ### What the UI reads | Query | Bounded by | |-------|-----------| | Trace list | A time window (1h, 24h or 7d), up to 100 traces per page, keyset paging | | Trace detail | Up to 2000 spans, ordered by start time | | Agent suggestions | A time window, up to 200 names | | Logs page | A time window, up to 200 records, newest first. Filters by agent, minimum severity, case-insensitive text and trace. | The UI never reads prompts or completions. See [Roadmap: open decisions](roadmap.md#open-decisions). ## Sensitive data By default, prompts and completions are stored in ClickHouse whenever an SDK records them with `RecordLLMCall` / `record_llm_call`. **To never store them, set `STORE_LLM_PAYLOADS=false` on the gateway.** - **What's dropped:** prompts and completions are removed at the gateway, before anything is published to NATS, logged or stored. - **What's kept:** the model, token counts and cost are always kept. - **Validation:** a span whose prompt would have been too large is still accepted, because the prompt is dropped before validation. - **Typos:** an unrecognized value stops the gateway at startup, rather than silently storing payloads. With storage on, treat ClickHouse access as access to prompt data. Payloads already stored aren't removed when you turn the switch off. ## Operational endpoints Every service exposes `/livez` and `/readyz`. The gateway and collector serve them on `HEALTH_ADDR` (default `:8081`); the UI serves them on its main port. See [Deployment: health checks](deployment.md#health-checks). ## Design decisions - **Separate health port for the gateway.** Its gRPC port requires mTLS client certificates, which Kubernetes and compose probes can't present. - **Generated code is committed.** The Go and Python protobuf stubs are in the repository, so a checkout builds with nothing but Go. CI fails if they drift from the `.proto` files. - **No JavaScript framework.** The UI uses `html/template`, htmx and two small scripts, all embedded in the binary. Its strict CSP rules out inline scripts and styles. - **ClickHouse as the system of record.** Only ClickHouse failures trigger redelivery. Treating the other sinks as best-effort keeps duplicate rows out of ClickHouse. --- # Deployment Conduit runs as three binaries, `gateway`, `collector` and `ui`. Each is shipped as a `FROM scratch` image built from `deployments/docker/Dockerfile` with `--build-arg BINARY=`. The binaries depend on NATS JetStream, ClickHouse, VictoriaMetrics and Tempo. | Component | Port | Talks to | |-----------|------|----------| | gateway | 4317 (gRPC, mTLS); 8081 health | NATS | | collector | 8081 health | NATS, ClickHouse, VictoriaMetrics, Tempo (OTLP/HTTP :4318) | | ui | 8080 (HTTP; `/healthz` and `/readyz` unauthenticated) | ClickHouse | ## Health checks Every service exposes unauthenticated health endpoints over plain HTTP: | Endpoint | Meaning | Gateway / collector | UI | |----------|---------|---------------------|----| | `/livez` (UI: `/healthz`) | The process is up | `HEALTH_ADDR`, default `:8081` | main port, 8080 | | `/readyz` | Dependencies are reachable: NATS for the gateway; NATS and ClickHouse for the collector; ClickHouse for the UI | `HEALTH_ADDR` | main port | - **Why a separate port for the gateway:** its gRPC port requires mTLS client certificates, which probes can't present. - **What `/readyz` returns:** a 200 or a 503, with each check reported only as `ok` or `unavailable`. The underlying errors go to the service log, so the endpoint never exposes hostnames or credentials. - **Health checks in scratch images:** the images have no shell or curl, so each binary includes `/app healthcheck`, which calls its own `/readyz` and exits 0 or 1. Compose health checks use it. - **Keep `HEALTH_ADDR` internal:** don't expose it outside the cluster. ## Local: quickstart `make quickstart` (or `deployments/quickstart/quickstart.sh`) runs the released images with nothing but Docker: - **Credentials:** the one-shot `conduit-init` container writes a dev CA, the gateway certificate, client certificates for `demo-agent` and `my-agent`, and a random UI login into `deployments/quickstart/.conduit/` (gitignored). It runs as your user, so the agent keys are readable by your own code, and it keeps existing files on later runs. - **Secrets:** the script writes a random ClickHouse password and session key to `deployments/quickstart/.env` (gitignored, owner-only). - **Network:** only the UI (`127.0.0.1:8080`) and the gateway (`127.0.0.1:4317`) listen, on loopback. Change the ports with `CONDUIT_UI_PORT` and `CONDUIT_GATEWAY_PORT`. - **Small machines:** ClickHouse uses a low-memory profile and system log tables are off, so the stack fits a 2 GiB Docker VM. Span retention is 7 days. - **Demo data:** a simulated `demo-agent` (no model calls; every span has `demo=true`) sends a run every 8 seconds. Start without it using `CONDUIT_DEMO_AGENT=0`, or stop it with `docker compose -p conduit-quickstart stop demo-agent`. - **Version:** images default to `v0.2.0`; set `CONDUIT_VERSION` to pick another release. **Get started:** until your own agent sends a trace, opening the UI lands on a Get started page (`/start`). It checks that the stack and the sample data are working, and shows the commands that make an agent certificate (on the command line: the UI never serves keys). It then gives SDK snippets and waits, live, for the first trace from an agent other than `demo-agent`, linking to it when it arrives. You can hide it from the page. `CONDUIT_FROM_SOURCE=1 quickstart.sh` builds the gateway, collector and UI from your checkout instead of pulling the release, one image at a time so it fits a 2 GiB Docker VM. `quickstart.sh status` reprints the login, `down` stops the stack, and `reset` deletes its data and credentials. `scripts/quickstart-check.sh` is the CI check: from a cold start, the first trace must be visible in the UI within 300 seconds. The quickstart is for local evaluation only. Its CA key sits next to the certificates it signs; use real PKI (below) for anything shared. ## Local: docker compose (from source) For working on Conduit itself, this stack builds the images from your checkout. ```bash ./scripts/gen-certs.sh # dev CA, gateway cert, example agent cert ./scripts/gen-certs.sh agent my-agent # more agents as needed # UI login (see security.md for other auth modes) printf '%s' 'a-long-dev-password' | go run ./cmd/ui hash-password \ | sed 's/^/admin:/' > deployments/docker/ui-users export CLICKHOUSE_PASSWORD=$(openssl rand -hex 16) docker compose -f deployments/docker/docker-compose.yml up --build ``` Then open http://localhost:8080 and sign in as `admin`. Agents connect to `localhost:4317` using `certs/ca.crt` and their own client cert (see [sdk-go.md](sdk-go.md)). To check the whole pipeline end to end, run `UI_USER=admin UI_PASSWORD=… ./scripts/smoke-test.sh` with the same `CLICKHOUSE_PASSWORD` exported. It sends a trace with the Go SDK and checks that it reaches ClickHouse, VictoriaMetrics, Tempo and the UI. CI runs it on every push. Compose sets `UI_COOKIE_INSECURE=true` because it serves plain HTTP on localhost. Never set it anywhere else. Without `UI_SESSION_KEY`, sessions end whenever the UI restarts. ## Kubernetes The manifests in `deployments/k8s/` assume a `conduit` namespace, [cert-manager](https://cert-manager.io), and NATS, ClickHouse, VictoriaMetrics and Tempo reachable at the hostnames in `configmap.yaml`. ### 1. Certificates (`mtls-cert.yaml`) The file creates a root CA, `conduit-ca-issuer`, a gateway server cert (secret `conduit-mtls-certs`) and an example agent cert. The gateway and the agents all chain to that one CA. The gateway Deployment maps the secret's `tls.crt`, `tls.key` and `ca.crt` onto `server.crt`, `server.key` and `ca.crt`. For production, swap the bootstrap self-signed root CA for your PKI but keep the issuer name. Issue one `Certificate` per agent with `usages: [client auth]`. Its `commonName` becomes the agent's identity. ### 2. Secrets Create `conduit-secrets` in the `conduit` namespace: | Key | Used by | Value | |-----|---------|-------| | `clickhouse-dsn` | collector, ui | `clickhouse://user:pass@host:9000/observability` | | `ui-users` | ui (login mode) | lines of `username:` | | `ui-session-key` | ui (login mode) | 32 or more random bytes, for example `openssl rand -hex 32` | ```bash kubectl -n conduit create secret generic conduit-secrets \ --from-literal=clickhouse-dsn="$CLICKHOUSE_DSN" \ --from-file=ui-users=./ui-users \ --from-literal=ui-session-key="$(openssl rand -hex 32)" ``` ### 3. Apply ```bash kubectl apply -f deployments/k8s/configmap.yaml \ -f deployments/k8s/mtls-cert.yaml \ -f deployments/k8s/deployment.yaml \ -f deployments/k8s/service.yaml ``` The gateway Service is a ClusterIP. Expose it through a LoadBalancer or a TCP/gRPC passthrough if agents run outside the cluster. TLS must terminate at the gateway itself, or mTLS identity is lost. On GKE, use `deployments/k8s/gke-iap.yaml` instead of the `ui` Service to put the UI behind Identity-Aware Proxy (see [security.md](security.md#iap-mode-google-cloud)). ### Upgrading an existing deployment - **Schema migrations:** the collector applies pending migrations at startup. To migrate before rolling out new collectors, for example in a Kubernetes Job, run `/app migrate` with the collector image and its environment. Never edit a shipped migration file; the collector refuses to start if a checksum changes. - **NATS stream:** the SPANS stream now uses work-queue retention, so acked spans are deleted. JetStream can't change the retention of an existing stream. Once, with the gateway and collector stopped, run `nats stream rm SPANS` so it's recreated with the new settings. - **UI auth:** `UI_API_TOKEN` is gone. Configure `UI_AUTH_MODE` and its settings (see [security.md](security.md#ui-authentication)). - **Tempo endpoint:** `TEMPO_ENDPOINT` must point at Tempo's **OTLP/HTTP** port, `:4318`. The collector posts to `/v1/traces`. Port `:4317` is OTLP/gRPC and won't work. ## Configuration reference | Variable | Component | Default | |----------|-----------|---------| | `GATEWAY_ADDR` | gateway | `:4317` | | `TLS_CERT`, `TLS_KEY`, `TLS_CA` | gateway | `/etc/certs/server.crt`, `server.key`, `ca.crt` | | `RATE_LIMIT_RPS` | gateway | `100` telemetry items per second per agent | | `HEALTH_ADDR` | gateway, collector | `:8081` (liveness and readiness endpoints) | | `SPAN_RETENTION_DAYS` | collector | `90` (1–3650). The collector reconciles the spans table's TTL on startup; shortening it deletes data that's now past retention. | | `STORE_LLM_PAYLOADS` | gateway | `true`. Set `false` to drop prompts and completions at ingestion; model, tokens and cost are kept. An unrecognized value stops startup. | | `NATS_URL` | gateway, collector | `nats://localhost:4222` | | `CLICKHOUSE_DSN` | collector, ui | local dev DSN. Always set it explicitly. Add `?secure=true` for TLS to ClickHouse. | | `VICTORIAMETRICS_URL` | collector | `http://localhost:8428` | | `TEMPO_ENDPOINT` | collector | `http://localhost:4318` | | `LISTEN_ADDR` | ui | `:8080` | | `UI_AUTH_MODE` and related | ui | see [security.md](security.md#ui-authentication) | | `UI_GATEWAY_ADDR` | ui | `localhost:4317`. The gateway address shown in the Get started page's snippets (display only). | | `UI_DEMO_AGENT` | ui | `demo-agent`. The sample agent whose traces don't count as a user's first trace on Get started. | --- # conduitctl `conduitctl` runs a local Conduit with one command and helps connect agents to it. It needs only Docker: the quickstart's compose files are built into the binary, and the released, signed images are pulled. ## Install Download the archive for your platform from the [latest release](https://github.com/tjscanlan/conduit/releases/latest), check it, and put `conduitctl` on your `PATH`: ```bash # Linux and macOS; pick linux|darwin and amd64|arm64 V=v0.2.0 OS=darwin ARCH=arm64 curl -LO https://github.com/tjscanlan/conduit/releases/download/$V/conduitctl_${V}_${OS}_${ARCH}.tar.gz curl -LO https://github.com/tjscanlan/conduit/releases/download/$V/SHA256SUMS curl -LO https://github.com/tjscanlan/conduit/releases/download/$V/SHA256SUMS.sigstore.json cosign verify-blob SHA256SUMS --bundle SHA256SUMS.sigstore.json \ --certificate-identity "https://github.com/tjscanlan/conduit/.github/workflows/release.yml@refs/tags/$V" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com # optional, recommended shasum -a 256 -c SHA256SUMS --ignore-missing tar -xzf conduitctl_${V}_${OS}_${ARCH}.tar.gz && sudo mv conduitctl_${V}_${OS}_${ARCH}/conduitctl /usr/local/bin/ ``` Or, with Go: `go install github.com/tjscanlan/conduit/cmd/conduitctl@latest`. `conduitctl` ships with v0.2.0 and later (v0.1.0 has no binaries, and its images lack the quickstart's `conduit-init` and `conduit-demo-agent`). From a checkout, `make quickstart` runs the same stack. ## Commands | Command | What it does | |---|---| | `conduitctl quickstart` | Writes the compose bundle and generated secrets to `~/.conduit/quickstart`, starts the stack, waits for health and the first demo trace, and prints the UI login and the `CONDUIT_*` settings for your agent. Running it again restarts with the same data and credentials. | | `conduitctl status` | Shows the services, the login, and the settings again. | | `conduitctl agent-cert NAME` | Issues a client certificate for agent `NAME`, signed by the quickstart's CA, and prints how to use it. Valid existing certificates are kept. | | `conduitctl doctor` | Checks Docker (running, Compose v2, at least 2 GiB of memory), free ports, every service's health, the UI, the gateway (an mTLS call as `my-agent`), and that demo data reached ClickHouse. Each failure says what to do. | | `conduitctl down` | Stops the stack, keeping data and credentials. | | `conduitctl reset` | Stops the stack and deletes its data and credentials. | | `conduitctl version` | Prints the version; images default to the same tag. | Flags (all commands): `-dir` (default `~/.conduit/quickstart`), `-ui-port` (default 8080), `-gateway-port` (default 4317), `-images` (image tag; default is the CLI's own version), and `-no-demo` (skip the simulated `demo-agent`). ## Things to know - **Local evaluation only:** the CA key sits next to the certificates it signs, and only `127.0.0.1` ports are opened. For anything shared, see [Deployment](deployment.md). - **One local stack at a time:** `conduitctl` and `make quickstart` run the same compose project (`conduit-quickstart`) and share its data volumes. So `docker compose -p conduit-quickstart logs gateway` works with either. - **Keep `-dir` under your home directory:** Docker Desktop and Colima share only some host folders with containers, usually your home. - **Development builds:** a `conduitctl` built from a checkout reports version `dev` and needs `-images`. `scripts/build-quickstart-images.sh ci-local` builds every image locally for `-images ci-local`; CI tests the CLI this way. --- # Security Conduit handles agent telemetry that can include sensitive data: prompts, completions, tool arguments and error messages. This document describes the threat model and the controls that address it. ## Threat model | Threat | Control | |--------|---------| | An unauthenticated client sends spans | The gateway requires a TLS 1.3 client certificate signed by your CA (`RequireAndVerifyClientCert`). | | An agent impersonates another agent | Identity is the certificate's CommonName, set by the gateway. A client-supplied `agent_name` attribute is overwritten. | | An agent floods ingestion, or a buggy one does | Per-identity token buckets charged per telemetry item, with a 1000-item batch cap ([below](#ingestion-rate-limiting)). | | Malformed or hostile payloads reach storage | Strict validation at the gateway: ID charset and size, name and attribute limits, status enum, timestamp sanity, LLM field bounds, finite non-negative cost. ClickHouse queries use bound parameters only. | | An SDK is tricked into trusting a fake gateway | The SDKs trust only the configured CA (`TLSCACert` / `tls_ca`), never the system roots. | | Unauthorized UI access | The UI fails closed: a login form with signed cookies, or GCP IAP JWT verification. `none` must be set explicitly ([below](#ui-authentication)). | | XSS, clickjacking and CSRF in the UI | `html/template` escaping, a strict CSP with no inline script or style, `frame-ancestors 'none'`, SameSite cookies, and same-origin checks on POST requests. | | Prompts or completions are retained against policy | `STORE_LLM_PAYLOADS=false` drops them at the gateway, before NATS, logs or storage ([architecture](architecture.md#sensitive-data)). | | Health endpoints leak internals | `/readyz` reports only `ok` or `unavailable`. Errors go to logs. | | Supply-chain compromise | Few dependencies, govulncheck in CI, GitHub Actions pinned by commit SHA, `FROM scratch` images, generated code pinned and drift-checked. | **Out of scope, or the operator's job:** - **Network isolation:** keep NATS, ClickHouse, Tempo, VictoriaMetrics and the health ports internal. - **Encryption at rest** for ClickHouse. - **Access to ClickHouse itself.** Anyone with ClickHouse access can read prompt data (see [Architecture: sensitive data](architecture.md#sensitive-data)). ## Ingestion rate limiting The gateway rate-limits each agent separately, using a token bucket keyed on the CommonName of its mTLS client certificate. Authentication runs first, so every request already has a verified identity when it reaches the limiter. `RATE_LIMIT_RPS` (default 100) counts telemetry items per second, not RPCs. An `IngestBatch` call costs one token for each span, metric and log it carries, so batching can't multiply an agent's throughput. Bursts can use up to `RATE_LIMIT_RPS` tokens at once. Some batches are rejected with `InvalidArgument` because they could never succeed: - the batch has more items than `RATE_LIMIT_RPS` - the batch has more than 1000 items A request rejected only because the bucket is currently empty gets `ResourceExhausted` and can be retried. ## UI authentication The UI (`cmd/ui`) always requires authentication unless you explicitly disable it. Pick a mode with `UI_AUTH_MODE`: | Mode | Use when | Identity comes from | |------|----------|---------------------| | `login` (default) | Self-hosted, or anywhere without an identity-aware proxy | Login form, then a signed session cookie | | `iap` | Running on Google Cloud behind Identity-Aware Proxy | IAP's signed JWT on each request | | `none` | Throwaway local development only | Nobody (every request is `anonymous`) | The UI refuses to start if the selected mode is misconfigured, for example `login` without a users file or `iap` without an audience. A misconfigured mode never quietly falls back to `none`. `GET /healthz` and `/static/` are always unauthenticated so load balancer health checks work. Neither serves any telemetry data. ### Login mode | Variable | Required | Description | |----------|----------|-------------| | `UI_USERS_FILE` | yes | One `username:bcrypt-hash` per line. `#` comments are allowed. | | `UI_SESSION_KEY` | recommended | HMAC key for session cookies, at least 32 bytes. Every replica must share it. If it's unset, a random key is generated at startup, so sessions are lost on restart and don't work across replicas. | | `UI_SESSION_TTL` | no | Session lifetime, `8h` by default. | | `UI_COOKIE_INSECURE` | no | `true` lets the cookie travel over plain HTTP. Use it only for local compose. | Create a users file entry with the UI binary itself. The runtime image has no shell, so this works without extra tools: ```bash printf '%s' "$PASSWORD" | go run ./cmd/ui hash-password # from source printf '%s' "$PASSWORD" | docker run --rm -i conduit-ui hash-password echo "alice:" >> deployments/docker/ui-users # gitignored ``` `htpasswd -nB alice` output works too. How login is protected: - Passwords are checked with bcrypt. When a username doesn't exist, the UI still compares against a dummy hash, so response timing doesn't reveal which usernames exist. - Sessions are stateless and signed with HMAC-SHA256. Nothing is stored server-side, so a session can't be revoked before it expires. To end every session at once, rotate `UI_SESSION_KEY`. - The session cookie is `__Host-conduit_session`, with `HttpOnly`, `Secure`, `SameSite=Lax` and `Path=/` set. - `POST /login` and `POST /logout` reject cross-site requests, checking `Sec-Fetch-Site` and falling back to `Origin`. This blocks login CSRF and forced logout. - Login attempts are rate limited to 5 per minute for each username and each client IP. - The `next` redirect parameter accepts only local paths, which prevents open redirects. ### IAP mode (Google Cloud) | Variable | Required | Description | |----------|----------|-------------| | `UI_IAP_AUDIENCE` | yes | `/projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID` for a load balancer backend, or `/projects/PROJECT_NUMBER/apps/PROJECT_ID` for App Engine. | On every request, the UI checks the `x-goog-iap-jwt-assertion` header: - **Signature:** must be ES256 and verify against Google's published keys at `https://www.gstatic.com/iap/verify/public_key-jwk`. The keys are cached for an hour and refetched when a new key ID appears, at most once per minute. - **Claims:** the issuer must be `https://cloud.google.com/iap`, the audience must match `UI_IAP_AUDIENCE`, and the token must not be expired. It never trusts the unsigned `x-goog-authenticated-user-email` header. A request that reaches the pod without passing through IAP, such as one from inside the cluster or through a misconfigured load balancer, is rejected. Clicking **Sign out** redirects to `/_gcp_iap/clear_login_cookie`. That's IAP's own logout endpoint. Use `deployments/k8s/gke-iap.yaml` to set this up on GKE: it has the BackendConfig with IAP enabled, the health check on `/healthz`, a ClusterIP Service, and an Ingress. Access is granted with `roles/iap.httpsResourceAccessor`. ### Browser hardening Every response carries a strict Content-Security-Policy that allows only same-origin scripts and styles, with no inline script and `frame-ancestors 'none'`. Responses also set `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and `Referrer-Policy: no-referrer`. Templates must not contain inline event handlers, `