This is the full developer documentation for obsei # Voice of Customer without violating privacy > The self-hosted, open source alternative to Enterpret and Unwrap that strips PII before any LLM sees it. Understand feedback from every channel and language. ## How it works [Section titled “How it works”](#how-it-works) [Collect](/guides/sources/) * App Store * Google Play * Zendesk * Intercom * Gong * GitHub * Bluesky * SQL * Webhooks * \+ any REST or MCP Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Theme**: across languages](/guides/themes/) 4. [**Store**: encrypted](/configuration/) [Act](/guides/sinks/) * Studio * AI agents via MCP * Slack * Jira * Linear * Warehouse Everything inside the dashed box runs on your machines. Read [how obsei works](/how-it-works/) for what each step does and who can see what. ## Why obsei [Section titled “Why obsei”](#why-obsei) Private by design Redaction at ingest, pseudonymous authors, an encrypted database, air-gapped by default. Bring your own Your sources, your keys and your model: a classifier that runs inside obsei by default, a small decision model, or any OpenAI-compatible model from Ollama to Azure OpenAI. AI-native An MCP server: results carry redacted quotes and record ids, never identities, and what agents propose waits for your approval. Every language Classified and quoted in its own language. With `obsei[embeddings]`, the same issue is grouped into one theme across languages. ## Start here [Section titled “Start here”](#start-here) [Quickstart](/quickstart/)Install, run the ten-language sample and ask your first question. [How it works](/how-it-works/)The pipeline, the trust boundary and who sees what. [Sources and sinks](/guides/sources/)Every built-in connector and how to map your own data. [Privacy controls](/privacy/overview/)Redaction by region, pseudonyms, erasure, retention and audit. [MCP and agents](/guides/mcp/)Connect Claude, ChatGPT, Cursor and your own agents. [Server and access](/guides/serve/)Webhooks, scheduling, Studio, SSO and roles. # Quickstart > Install obsei, create a project and run your first pipeline. Five minutes, no model server and no secrets to export: ```bash mkdir voc && cd voc uvx --from "obsei[recommended]>=1.0" obsei init # project, secrets, sample data, local model obsei try # redacted, labelled preview of the sample obsei try https://apps.apple.com/us/app/whatsapp-messenger/id310633997 # any public link obsei run # fetch, redact, label, deliver, store ``` Then add your own sources and sinks without editing YAML: `obsei add appstore`, `obsei add slack`, `obsei check` (see [Set up without editing YAML](/guides/setup/)). `obsei[recommended]` is the full local setup: the classifier (`local`) and multilingual themes (`embeddings`). `uvx` runs obsei without installing it; to keep the `obsei` command, install it once with `uv tool install "obsei[recommended]"` (or `pip install`). `obsei init` creates: * `obsei.yaml`, with the `classify` enricher on when `obsei[local]` is installed, and `feedback.csv`, a sample in ten languages with a few synthetic emails, phone numbers and card numbers so you can see redaction work. * `.obsei/secrets.env` with a random database key (`OBSEI_DB_KEY`) and author pseudonym salt (`OBSEI_PSEUDONYM_SALT`), readable only by you, and `.obsei/` in `.gitignore`. Every command loads it; variables already set in the environment win, and `obsei doctor` shows where each came from. Back the file up: without the key the database cannot be read, and a new salt produces new pseudonyms. `obsei init` never overwrites it. * DuckDB’s `httpfs` extension for encryption and, with `obsei[local]`, the classifier model (about 2.2 GB, once). `--offline` skips both downloads. On servers and in CI, set `OBSEI_DB_KEY` and `OBSEI_PSEUDONYM_SALT` from your secret manager, or mount a `.obsei/secrets.env` next to `obsei.yaml`. ## Preview with `obsei try` [Section titled “Preview with obsei try”](#preview-with-obsei-try) `obsei try` prints a table of up to 10 records per source (`--limit`): source, language, labels with the model’s confidence (`review` marks answers below the cutoff) and the redacted text. Below it, the number of values redacted per type and before and after for three records. Nothing is stored or sent; the original text exists only in that preview. `--json` prints one JSON object per record instead, and `--no-enrich` skips the classifier. If the classifier cannot run, the table still shows redaction and a warning says why. Without an argument it reads the sources in `obsei.yaml`. With a link or a file it reads only that, with redaction always on and the enrichers of the first pipeline (or `-p NAME`); outside a project, with the local classifier when `obsei[local]` is installed: | Argument | Source | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `https://apps.apple.com//app//id` | `appstore`, that app and country | | `https://github.com//` | `github_issues`, without a token | | `https://news.ycombinator.com/item?id=` or an `hn.algolia.com` search | `hackernews` | | `https://wordpress.org/support/plugin//reviews/` | `rss`, the plugin’s review feed | | Any other link | `rss` (RSS 2.0 or Atom) | | `.csv`, `.jsonl`, `.json` file | `csv`, `jsonl` or `filedrop`, text from a `text`, `comment`, `review`, `body`, `content`, `message` or `feedback` column | Google Play, YouTube and Bluesky need credentials, so `obsei try` names the source to configure instead. The preview fetches only from the link’s host. The egress policy governs where feedback is sent (models and sinks), and the local classifier runs in process, so nothing else is contacted. If `obsei run` says “writing an encrypted store needs DuckDB’s httpfs extension” Encryption uses DuckDB’s `httpfs` extension, and the default air-gapped mode never downloads it. Up to 1.0.0rc2, install it once yourself; this fetches DuckDB’s own extension and sends none of your data: ```bash "$(uv tool dir)/obsei/bin/python" -c "import duckdb; duckdb.connect().execute('INSTALL httpfs')" # installed with pip: run python -c "..." with the same command, in the environment that has obsei ``` Then `obsei doctor` reports `encryption: ready`. From 1.0.0rc3, `obsei init` installs it for you. For machines without network access, see [At rest and in use](/privacy/overview/#at-rest-and-in-use). ## Optional extras [Section titled “Optional extras”](#optional-extras) Some features need extra packages. Add them to the same install: | Extra | Adds | | ------------- | -------------------------------------------------------------- | | `recommended` | `local` and `embeddings`: everything that runs on this machine | | `mcp` | `obsei serve`, `obsei mcp` and Studio over HTTP | | `local` | the local classifier for `classify` (PyTorch; no model server) | | `embeddings` | the local multilingual model, so themes group across languages | | `names` | local name redaction (GLiNER) | | `sql` | the SQL source and sink | | `google` | the Google Play source | | `apple` | the App Store Connect source | ```bash # uv tool: --force replaces the install, so list every extra you want to keep uv tool install --force "obsei[mcp,embeddings]" # pip, in the environment where obsei is installed pip install "obsei[mcp,embeddings]" ``` `uv run --extra ...` only works inside a project checkout; outside one it ignores the extra. Plugins install the same way: `uv tool install --force obsei --with obsei-teams` (see [Plugins](/reference/plugins/)). ## Add a model [Section titled “Add a model”](#add-a-model) The default classifier runs inside obsei on the CPU, so there is no model server to start. If you ran `obsei init` without `obsei[local]`, add it and fetch the model: ```bash uv tool install --force "obsei[local]" # or: pip install "obsei[local]" obsei models download # once, with network (about 2.2 GB) ``` Then uncomment the `classify` enricher in `obsei.yaml`. Every record gets sentiment, intent and language, with a probability for each answer; answers below the cutoff are marked for review. To use a decision model, Ollama or a hosted LLM instead, or to add the chat model that `obsei ask` needs, see [Models](/guides/models/). ## Ask your agent [Section titled “Ask your agent”](#ask-your-agent) ```bash claude mcp add obsei -- obsei mcp ``` Then ask: *“What are the top complaints this month, by country?”* See [MCP and agents](/guides/mcp/). ## Run with Docker [Section titled “Run with Docker”](#run-with-docker) Run inside the project directory. The image’s entrypoint is `obsei`, so arguments are subcommands: ```bash docker run --rm -v "$PWD:/data" -w /data --user "$(id -u):$(id -g)" \ ghcr.io/obsei/obsei:1.0.0 run ``` The container reads `.obsei/secrets.env` from the mounted project; pass `-e OBSEI_DB_KEY -e OBSEI_PSEUDONYM_SALT` instead when the secrets come from the environment. Images are tagged by release (`1.0.0`); there is no `latest` tag yet. `--user` keeps the database and outputs owned by you. ## Docker Compose [Section titled “Docker Compose”](#docker-compose) [`docker-compose.yml`](https://github.com/obsei/obsei/blob/master/docker-compose.yml) runs `obsei serve` with the store in a named volume and your `obsei.yaml` mounted read-only. Copy it and [`.env.example`](https://github.com/obsei/obsei/blob/master/.env.example) into your project directory: ```bash cp .env.example .env # fill in OBSEI_DB_KEY, OBSEI_PSEUDONYM_SALT (e.g. from .obsei/secrets.env) and OBSEI_API_TOKEN docker compose up -d docker compose ps # healthy once /healthz answers ``` Studio is at `http://localhost:8765/studio/` and MCP at `http://localhost:8765/mcp`, both behind `OBSEI_API_TOKEN` (see [Server and webhooks](/guides/serve/)). The port is published on `127.0.0.1` only; change the mapping to expose it. Files your pipelines read, such as a CSV, need their own mount under `/data`. # How it works > The obsei pipeline, the trust boundary and who sees what. [Collect](/guides/sources/) * App Store * Google Play * Zendesk * Intercom * Gong * GitHub * Bluesky * SQL * Webhooks * \+ any REST or MCP Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Theme**: across languages](/guides/themes/) 4. [**Store**: encrypted](/configuration/) [Act](/guides/sinks/) * Studio * AI agents via MCP * Slack * Jira * Linear * Warehouse obsei is one Python process (or container) that you run. Everything in the dashed box happens on your machines; data only leaves it for endpoints you configure and allow. 1. **Collect.** [Sources](/guides/sources/) fetch new feedback from app stores, helpdesks, call tools, warehouses, communities, files, webhooks or any REST or MCP server. Each record gets a stable id, so re-runs only process new or changed feedback. 2. **Redact.** Before anything is stored or sent to a model, emails, phones, cards, IBANs and national IDs for [seven regions](/privacy/overview/) are replaced with tokens such as ``, optionally names too. Authors become salted pseudonyms. 3. **Classify.** A [local classifier](/guides/models/#local-classifier), a decision model or your own chat model labels sentiment, intent, urgency and custom fields, each with a probability. Unsure answers go to a stronger model or to a human, and spam can be dropped here. Models only see redacted text, and the egress policy blocks public endpoints unless you allow them. 4. **Theme.** Records are embedded and grouped into stable [themes](/guides/themes/) with trends and near-duplicate detection. Groups smaller than k (default 5) are hidden. 5. **Store.** Everything lands in an encrypted DuckDB file. `obsei forget`, `obsei export` and the audit log cover access, erasure and retention. 6. **Act.** [Sinks](/guides/sinks/) deliver new feedback to Slack, Jira, Linear, GitHub, webhooks, Parquet or SQL. People use [Studio](/guides/studio/) and Slack; agents use the [MCP server](/guides/mcp/), where anything they propose waits for a person to approve it. ## Who sees what [Section titled “Who sees what”](#who-sees-what) | Who | Themes | Redacted quotes | Pseudonyms | Raw text | | -------------------- | ------ | --------------- | ---------- | -------- | | Studio viewer | ✓ | | | | | Studio analyst, Ask | ✓ | ✓ | | | | AI agents (MCP) | ✓ | ✓ | | | | Your model | | ✓ | | | | Sinks | | ✓ | opt-in | | | `obsei export` (CLI) | | ✓ | ✓ | | Raw text with personal data is never stored. Roles are set per token or through your SSO proxy; see [Server and access](/guides/serve/). ## Run modes [Section titled “Run modes”](#run-modes) | Command | Use | | ------------- | ------------------------------------------------------- | | `obsei try` | Preview redacted records; nothing stored or sent | | `obsei run` | One pass of every pipeline, from cron or CI | | `obsei serve` | Scheduled pipelines, webhooks, Studio and MCP over HTTP | | `obsei mcp` | MCP over stdio for a local agent | # Configuration > The obsei.yaml file. `obsei.yaml` declares pipelines built only from registered plugins, so a config file can never import arbitrary code. Secrets never go in the file: fields ending in `_env` name the environment variable that holds the value. `obsei add` writes sources, sinks and alerts into it for you, and with the schema line below your editor completes and checks every key, including each plugin’s `config` ([Set up without editing YAML](/guides/setup/)). ```yaml # yaml-language-server: $schema=https://docs.obsei.com/schema/obsei.schema.json version: 1 store: path: obsei.duckdb # OBSEI_DB overrides unencrypted: false # true only on an encrypted disk # air-gapped: pre-install httpfs, set OBSEI_DUCKDB_EXTENSIONS privacy: redact: true regions: [global, north_america, uk, eu, latam, apac, india, africa] egress: # optional; otherwise OBSEI_EGRESS_MODE / OBSEI_EGRESS_ALLOW mode: private allowed_hosts: [my-tenant.openai.azure.com] llms: default: api: local # in-process classifier (obsei[local]) ollama: base_url: http://localhost:11434/v1 model: qwen3:8b azure: base_url: https://my-tenant.openai.azure.com/openai/v1 model: gpt-5-mini api_key_env: AZURE_OPENAI_API_KEY max_requests: 2000 plugins: [reddit] # installed plugins allowed to load (reddit: see Sources) pipelines: - name: reviews batch_size: 100 sources: - key: ios type: appstore config: {app_id: "284882215", countries: [us, de, br, jp, in, ng]} enrichers: - type: classify config: {llm: default, fallback_llm: azure, threshold: 0.7} sinks: - type: slack config: {when: {classify.intent: [bug, churn_risk]}} ``` Sinks take an optional `key` (default: their type), and a pipeline can add ordered `route:` rules that send each record to the sinks of the first rule it matches; see [Routing](/guides/routing/). ## Alerts [Section titled “Alerts”](#alerts) Top-level `alerts:` watch aggregates rather than records: theme spikes, new themes, digests, the review queue and pipeline health. Each names the sinks that deliver it in `to`, written like pipeline sinks; see [Alerts](/guides/alerts/). ```yaml alerts: - name: spikes kind: theme_spike # new_theme, digest, review_queue or pipeline_health to: - type: slack config: {webhook_url_env: SLACK_ALERTS_URL} ``` ## Enrichers [Section titled “Enrichers”](#enrichers) Enrichers run in the order listed, on new or changed records only. | Type | What | Model | | ---------- | ---------------------------------------------------------------------- | ----------------------- | | `filter` | One yes/no question per record; matching records are tagged or dropped | decision | | `classify` | Sentiment, intent, language and your own fields, with a confidence | local, decision or chat | `classify` takes `llm`, `fallback_llm`, `threshold`, `min_confidence`, `sentiments`, `intents` and `fields`; see [Models](/guides/models/) for local and decision endpoints, option descriptions and cutoffs. Put `filter` first so dropped records never reach the classifier: ```yaml enrichers: - type: filter config: llm: julia # an llms entry with api: decision question: Is this spam rather than a real customer request? yes_means: prize scams, phishing links, advertising or bulk mail # optional, with no_means no_means: a customer writing about a product, an account or a bill threshold: 0.8 # probability of yes from which a record matches (default 0.5) action: drop # or tag (default): keep it with filter.match: true - type: classify config: {llm: julia, fallback_llm: ollama} ``` Dropped records are not classified, delivered or stored; the run summary counts them (`dropped 3`). A source that returns the same record again (a feed without a cursor) has it checked again. With `action: tag`, sinks can skip matches with `when: {filter.match: ["false"]}`. ## Ask [Section titled “Ask”](#ask) `obsei ask`, `POST /api/ask` and the Slack command answer from your feedback with `ask_llm`. Add `ask_judge`, a decision endpoint, to check each answer against the records it cites: the judge gets only the question, the answer and the cited records (redacted, as stored) and answers “Is every statement in the answer supported by the quoted records?”. ```yaml ask_llm: ollama ask_judge: kev # an llms entry with api: decision ask_judge_threshold: 0.5 # default ``` The CLI prints `grounded: 0.93`, and below the threshold warns that the answer may not be supported. The API returns `grounded` and `possibly_unsupported`, and Slack replies carry a warning line. Judging needs a capable model: in our tests Julia-1 did not separate supported from unsupported answers, so use a 4B or larger decision model (Kev-4B, lev, Clef-Flash) as the judge. ## How a run works [Section titled “How a run works”](#how-a-run-works) 1. Each source resumes from its saved cursor and yields records. 2. Records are redacted, then compared with the stored copy; unchanged records stop here. 3. Enrichers label new or changed records; a `filter` with `action: drop` removes records here. 4. Records go to sinks: with a [`route:`](/guides/routing/), each sink a route names gets only the records routed to it, and every other sink gets the batch through its own `when`. Only then is the batch stored and the cursor advanced, so a failed run retries the same batch (at-least-once; sinks are idempotent by record id). A failing pipeline is reported and the others still run; `obsei run` then exits with status 1. ## Scheduling [Section titled “Scheduling”](#scheduling) Set `every_minutes` on a pipeline and `obsei serve` runs it on that schedule, sharing the server’s database. DuckDB allows a single writer, so do not point a separate `obsei run` at a database that `obsei serve` holds. `themes.auto: true` updates themes after each scheduled run, alerts are checked after that, and `GET /api/runs` reports the last run of each pipeline. ```yaml themes: auto: true pipelines: - name: reviews every_minutes: 60 sources: [...] ``` Without a server, `obsei run --every 60` repeats the run every hour. # Examples > Complete obsei.yaml files for common Voice of Customer setups. Each example is a complete, commented `obsei.yaml` in the [`examples/`](https://github.com/obsei/obsei/tree/master/examples) folder. The header of each file lists the extras to install, the environment variables it reads and how to run it. Every example is loaded with the real config loader in CI, so the keys match the current release. ```bash git clone https://github.com/obsei/obsei && cd obsei obsei init examples --offline # secrets in examples/.obsei/secrets.env obsei try -c examples/social-listening.yaml # preview redacted records; nothing stored or sent obsei run -c examples/social-listening.yaml ``` Copy a file to `obsei.yaml` in your project and replace the placeholder ids, hosts and names. Secrets are read from environment variables, or from `.obsei/secrets.env` next to the config. ## App reviews in six countries [Section titled “App reviews in six countries”](#app-reviews-in-six-countries) App Store reviews from the US, UK, India, Brazil, Japan and Germany plus Google Play, classified by a local Ollama model, with negative reviews posted to Slack and multilingual themes. [Sources](/guides/sources/) * App Store ×6 countries * Google Play Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Theme**: across languages](/guides/themes/) 4. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Slack Extras: `google,embeddings` [app-reviews-multi-country.yaml](https://github.com/obsei/obsei/blob/master/examples/app-reviews-multi-country.yaml) ## Support tickets with a model cascade [Section titled “Support tickets with a model cascade”](#support-tickets-with-a-model-cascade) Zendesk, Freshdesk and Intercom (EU region) tickets labelled by a small model; low-confidence results go to a larger in-house model. Bugs become Jira issues, feature requests Linear issues, and everything is exported to Parquet. [Sources](/guides/sources/) * Zendesk * Freshdesk * Intercom Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Jira * Linear * Parquet [support-tickets.yaml](https://github.com/obsei/obsei/blob/master/examples/support-tickets.yaml) ## Multilingual ticket routing with a decision model [Section titled “Multilingual ticket routing with a decision model”](#multilingual-ticket-routing-with-a-decision-model) Zendesk and Freshdesk tickets in any language, labelled on a CPU by Julia-1, a 144M decision model served by llama.cpp: spam is filtered out first, then one request per ticket answers sentiment, intent (with descriptions), an urgency score and a yes/no refund question. Answers below their cutoff go to a local Ollama chat model. Ordered [routes](/guides/routing/) then pick one destination per ticket: uncertain tickets go to a review channel, confident urgent bugs open a Jira issue and page on-call, billing and refund requests go to the billing channel, and the rest lands in Parquet. A CSV of eight sample tickets in seven languages lets you try it locally: `obsei try -c examples/decision-routing.yaml -p sample --enrich --limit 8`. [Sources](/guides/sources/) * Zendesk * Freshdesk Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Jira * Slack ×3 * Parquet [decision-routing.yaml](https://github.com/obsei/obsei/blob/master/examples/decision-routing.yaml) ## Social listening [Section titled “Social listening”](#social-listening) Bluesky (English and Portuguese), Hacker News, YouTube comments, a forum RSS feed and a Mastodon hashtag through the REST source, delivered to an internal service as signed webhooks. [Sources](/guides/sources/) * Bluesky * Hacker News * YouTube * RSS * Mastodon (REST) Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Signed webhook [social-listening.yaml](https://github.com/obsei/obsei/blob/master/examples/social-listening.yaml) ## Trustpilot reviews over REST [Section titled “Trustpilot reviews over REST”](#trustpilot-reviews-over-rest) Trustpilot Business reviews through the generic REST source, with the API key in a secret header, star ratings mapped and 1-2 star reviews posted to Slack. Field names follow the public business-unit reviews endpoint; verify them against your account’s API version. [Sources](/guides/sources/) * Trustpilot (REST) Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Slack [trustpilot-rest.yaml](https://github.com/obsei/obsei/blob/master/examples/trustpilot-rest.yaml) ## CRM and ITSM over REST [Section titled “CRM and ITSM over REST”](#crm-and-itsm-over-rest) HubSpot tickets (cursor paging on `paging.next.after`) and ServiceNow incidents (Table API with `sysparm_*` parameters), classified with custom fields; bugs and outages go to Jira Data Center. [Sources](/guides/sources/) * HubSpot (REST) * ServiceNow (REST) Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Jira * Parquet [crm-and-itsm-rest.yaml](https://github.com/obsei/obsei/blob/master/examples/crm-and-itsm-rest.yaml) ## More review sites and wikis [Section titled “More review sites and wikis”](#more-review-sites-and-wikis) Four smaller recipes, each one source with the local classifier: * **Atlassian Marketplace** reviews of your app through the public Marketplace API, no key; 1-2 star reviews go to Slack. * **Google Business Profile** reviews of the locations you manage, with an OAuth token (beta: the API needs approved access, and the token lasts an hour). * **Confluence Cloud** pages of a space, such as customer interview notes, with an API token. * **WordPress.org** reviews of your plugin from its public review feed. [Sources](/guides/sources/) * Atlassian Marketplace * Google Business Profile * Confluence * WordPress.org Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Slack * Parquet [atlassian-marketplace-reviews.yaml](https://github.com/obsei/obsei/blob/master/examples/atlassian-marketplace-reviews.yaml) [google-business-reviews.yaml](https://github.com/obsei/obsei/blob/master/examples/google-business-reviews.yaml) [confluence-pages.yaml](https://github.com/obsei/obsei/blob/master/examples/confluence-pages.yaml) [wordpress-plugin-reviews.yaml](https://github.com/obsei/obsei/blob/master/examples/wordpress-plugin-reviews.yaml) Telegram, Discord and email notifications need no recipe: the [`notify` sink](/guides/sinks/#notifications) sends to them and 100+ other services. ## Warehouse round trip [Section titled “Warehouse round trip”](#warehouse-round-trip) NPS responses read incrementally from Postgres, Snowflake or BigQuery, classified, and written back to a warehouse table without author pseudonyms. [Sources](/guides/sources/) * SQL warehouse Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * SQL warehouse Extras: `sql` plus your database driver [warehouse.yaml](https://github.com/obsei/obsei/blob/master/examples/warehouse.yaml) ## Mailbox and file drops [Section titled “Mailbox and file drops”](#mailbox-and-file-drops) A support mailbox over IMAP and a drop folder for exported CSV and JSON Lines survey and NPS files, with churn risks posted to Slack and a Parquet export. [Sources](/guides/sources/) * IMAP mailbox * File drop Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Slack * Parquet [email-and-files.yaml](https://github.com/obsei/obsei/blob/master/examples/email-and-files.yaml) ## Enterprise, air-gapped [Section titled “Enterprise, air-gapped”](#enterprise-air-gapped) vLLM with an Azure OpenAI fallback behind a private egress allowlist, person-name redaction from a local model, regional redaction, local multilingual embeddings, `k_anonymity: 10`, `obsei serve` behind an SSO proxy with group-to-role mapping, API tokens, `allowed_hosts` and a retention job. [Sources](/guides/sources/) * GitHub Enterprise * Webhooks * SQL Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Theme**: across languages](/guides/themes/) 4. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Jira · extras `mcp,names,embeddings,sql` [enterprise-air-gapped.yaml](https://github.com/obsei/obsei/blob/master/examples/enterprise-air-gapped.yaml) ## India, Brazil and Japan [Section titled “India, Brazil and Japan”](#india-brazil-and-japan) One pipeline per market with market-specific intents, India, Latin America and Asia-Pacific redaction plus name redaction, multilingual themes labelled in one language, and a Slack channel per team. [Sources](/guides/sources/) * App Store * Freshdesk * Intercom * Bluesky Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Theme**: across languages](/guides/themes/) 4. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Slack · extras `names,embeddings` [regional-india-brazil-japan.yaml](https://github.com/obsei/obsei/blob/master/examples/regional-india-brazil-japan.yaml) ## GitHub Actions [Section titled “GitHub Actions”](#github-actions) A daily workflow: App Store and Google Play reviews, classified, turned into GitHub issues and Slack messages, with the encrypted database kept in the Actions cache. See [GitHub Action](/guides/github-action/). [Sources](/guides/sources/) * App Store * Google Play Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * GitHub issues * Slack [examples/github-actions](https://github.com/obsei/obsei/tree/master/examples/github-actions) ## With plugins [Section titled “With plugins”](#with-plugins) These use [plugins](/reference/plugins/) that are installed separately and allowed with `plugins:` in the file. ### Surveys from Typeform [Section titled “Surveys from Typeform”](#surveys-from-typeform) Typeform survey responses plus a CSV NPS export, classified and grouped into themes, exported to Parquet. [Sources](/guides/sources/) * Typeform * NPS CSV Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Theme**: across languages](/guides/themes/) 4. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Parquet [surveys-typeform.yaml](https://github.com/obsei/obsei/blob/master/examples/surveys-typeform.yaml) ### Alerts in Microsoft Teams [Section titled “Alerts in Microsoft Teams”](#alerts-in-microsoft-teams) Negative App Store and Google Play reviews posted to a Teams channel. [Sources](/guides/sources/) * App Store * Google Play Your infrastructure 1. [**Redact**: PII, names optional](/privacy/overview/) 2. [**Classify**: decision model or LLM](/guides/models/) 3. [**Store**: encrypted](/configuration/) [Sinks](/guides/sinks/) * Microsoft Teams [alerts-teams.yaml](https://github.com/obsei/obsei/blob/master/examples/alerts-teams.yaml) # Use obsei as the privacy step for your agent > Give agents customer feedback through obsei's MCP server, with PII stripped at ingest, and copy-paste prompts for coding agents. Point your agent at obsei instead of at the raw sources. obsei fetches the feedback, redacts it and stores it; the agent reads only what was stored, through obsei’s MCP server, and anything it wants to send out waits for a person to approve it. ## Before any model sees text [Section titled “Before any model sees text”](#before-any-model-sees-text) Each run fetches, redacts, enriches, delivers and stores, in that order (see [How it works](/how-it-works/)). By the time text reaches a model, whether your `classify` endpoint or an agent over MCP, it has already been through: * **Redaction.** Emails, phone numbers, cards, national IDs and other PII for the regions in `privacy.regions` become placeholders such as ``. Names become `` when `privacy.names` is enabled (`obsei[names]`). * **Pseudonymization.** Authors become salted pseudonyms (`psn_...`). MCP results never contain them, not even the pseudonym. * **k-anonymity.** `list_themes` hides themes from fewer than `k_anonymity` people. Raw text with personal data is never stored, so an agent cannot recover it. See [Privacy controls](/privacy/overview/) for the detectors and their limits. ## What the MCP server exposes [Section titled “What the MCP server exposes”](#what-the-mcp-server-exposes) | Tool | Use | | ----------------- | --------------------------------------------------------------------------------------------- | | `feedback_stats` | Counts and average rating grouped by source, sentiment, intent, language, route, day and more | | `search_feedback` | Matching feedback, newest first, as citable evidence with record ids | | `get_feedback` | One record by id, to verify a citation | | `list_themes` | Recurring themes with 7-day trend, sources, languages and intents | | `propose_issue` | Queue a GitHub, Jira or Linear issue for a person to approve | The `voc_report` prompt drafts a cited Voice of Customer report for a topic and period. Two transports serve the same tools: | Transport | Start | Access | | --------------- | ------------------------ | ------------------------------------------------------------------------------------ | | stdio | `obsei mcp` | opens the store read-only on the local machine (read-write only to queue a proposal) | | streamable HTTP | `obsei serve`, at `/mcp` | analyst role: `Authorization: Bearer ` or your SSO proxy | Setup for Claude, Codex, Copilot, Cursor, Gemini and other tools is in [Use obsei from your AI tool](/guides/ai-tools/). ## Customer text is untrusted input [Section titled “Customer text is untrusted input”](#customer-text-is-untrusted-input) Feedback is written by anyone who can leave a review or open a ticket, so it can contain instructions aimed at your agent. Every evidence item is marked `"untrusted": true`, and the server and tool descriptions tell clients to treat `text` as data: quote it, cite record ids, and never follow instructions found inside it. Keep that rule in your own agent’s system prompt too, and don’t give the same agent unreviewed write access to other systems. ## Agents propose, people approve [Section titled “Agents propose, people approve”](#agents-propose-people-approve) An agent that reads private data and untrusted text must not also be able to send messages out on its own: that combination is what a prompt injection needs. So obsei follows one rule: * **Fixed labels may act automatically.** A classifier’s labels (intent, sentiment, your fields) can pick [routes](/guides/routing/) and sinks without review, because they come from a fixed list and cannot carry instructions. * **Free text needs approval.** Anything an agent wrote after reading feedback, such as an issue title and body, waits in obsei’s approval queue until a person approves it. `propose_issue` queues an issue for a configured GitHub, Jira or Linear sink and returns an approval id; nothing is sent. A person reviews it with `obsei approvals show` and approves or rejects it (CLI, or admin over HTTP). The issue opens once, marked as proposed by an AI agent, with links to the cited feedback. Agents cannot label, delete, route or approve anything. See [Approvals](/guides/mcp/#approvals). ## Prompts for coding agents [Section titled “Prompts for coding agents”](#prompts-for-coding-agents) Copy one into Claude Code, Cursor or another coding agent. The commands under each prompt are what the agent should run, so you can check its work. Agents can read the whole docs site at [`/llms-full.txt`](https://docs.obsei.com/llms-full.txt). ### App Store reviews to Slack [Section titled “App Store reviews to Slack”](#app-store-reviews-to-slack) > Set up obsei to pull App Store reviews for app 284882215 in the US, UK and Germany, classify them with my local Ollama model, and send negative ones to Slack. Read first. Don’t put secrets in obsei.yaml. ```bash uv tool install "obsei[local]" mkdir voc && cd voc && obsei init # secrets go to .obsei/secrets.env export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." # from you, not invented ``` Then it edits `obsei.yaml`: ```yaml egress: mode: private allowed_hosts: [hooks.slack.com] pipelines: - name: app-reviews sources: - key: ios type: appstore config: {app_id: "284882215", countries: [us, gb, de]} enrichers: - type: classify config: {llm: default} sinks: - type: slack config: when: {classify.sentiment: [negative]} max_messages: 20 ``` ```bash obsei try # redacted, labelled preview; nothing stored or sent obsei run ``` ### Ask questions about customers [Section titled “Ask questions about customers”](#ask-questions-about-customers) > Connect yourself to obsei over MCP and tell me the top complaints this month, by country, with quotes cited by record id. ```bash claude mcp add obsei -- obsei mcp ``` ### Remote MCP from Docker Compose [Section titled “Remote MCP from Docker Compose”](#remote-mcp-from-docker-compose) > Run obsei with the repository’s docker-compose.yml in this directory and connect to its MCP server over HTTP. ```bash cp .env.example .env # agent fills it with openssl rand -hex 24 values docker compose up -d claude mcp add --transport http obsei http://localhost:8765/mcp \ --header "Authorization: Bearer $OBSEI_API_TOKEN" ``` See [Docker Compose](/quickstart/#docker-compose). # Use obsei from your AI tool > Install the obsei MCP server and voice-of-customer skill in Claude, Codex, Copilot, VS Code, Cursor, Gemini and other AI tools. Every tool runs the same local MCP server over stdio: ```bash uvx --from "obsei>=1.0" obsei mcp ``` `uvx` comes with [uv](https://docs.astral.sh/uv/). The `>=1.0` floor stops uvx from reusing an older cached obsei, such as a release candidate. The first start downloads obsei, so give the server a startup timeout of about 60 seconds where the tool has one. The server reads `obsei.yaml` in the working directory, or the file named by `OBSEI_CONFIG`. Set `OBSEI_DB` for a database somewhere else and `OBSEI_DB_KEY` when it is encrypted. It opens the store read-only. The plugins for Claude Code, Codex, Copilot, VS Code, Cursor and Gemini also add the `voice-of-customer` skill: one [`SKILL.md`](https://github.com/obsei/obsei/blob/master/integrations/claude/obsei/skills/voice-of-customer/SKILL.md) shared by all of them. [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=obsei\&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyItLWZyb20iLCJvYnNlaT49MS4wIiwib2JzZWkiLCJtY3AiXX0%3D) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_obsei-0098FF?style=flat-square\&logo=visualstudiocode\&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=obsei\&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22--from%22%2C%22obsei%3E%3D1.0%22%2C%22obsei%22%2C%22mcp%22%5D%7D) | Tool | Install | Notes | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Claude Code | `/plugin install obsei --marketplace obsei/obsei` | MCP server and skill. Older versions: `/plugin marketplace add obsei/obsei`, then `/plugin install obsei@obsei` | | Codex | `codex plugin marketplace add obsei/obsei`, then `codex plugin add obsei@obsei` | MCP server and skill. MCP only: [below](#codex) | | GitHub Copilot CLI | `copilot plugin install obsei/obsei:integrations/claude/obsei` | MCP server and skill | | VS Code (Copilot agent mode) | button above, or `code --add-mcp '{"name":"obsei","command":"uvx","args":["--from","obsei>=1.0","obsei","mcp"]}'` | MCP server. For the skill too, run **Chat: Install Plugin From Source** with `https://github.com/obsei/obsei` | | Cursor | button above | MCP server. The plugin with the skill is awaiting Cursor Marketplace review | | Gemini CLI | `gemini extensions install https://github.com/obsei/obsei` | Asks for `OBSEI_CONFIG`, `OBSEI_DB` and `OBSEI_DB_KEY`; Gemini passes only these to the server | | Antigravity | `agy plugin import gemini` | Imports the Gemini CLI extension above | | Claude Desktop | [JSON config](#json-config) in `claude_desktop_config.json` | | | Cline | ask Cline to install `https://github.com/obsei/obsei` | It follows [`llms-install.md`](https://github.com/obsei/obsei/blob/master/llms-install.md) | | Devin Desktop (formerly Windsurf) | [JSON config](#json-config) in `~/.config/devin/mcp_config.json` | | | Zed | [Zed settings](#zed) | | | JetBrains AI Assistant | **Settings > Tools > AI Assistant > Model Context Protocol (MCP)**, **Add**, paste the [JSON config](#json-config) | | | Junie | [JSON config](#json-config) in `.junie/mcp/mcp.json` | | | Kiro | [JSON config](#json-config) in `.kiro/settings/mcp.json` | | | Goose | [Install in Goose](goose://extension?cmd=uvx\&arg=--from\&arg=obsei%3E%3D1.0\&arg=obsei\&arg=mcp\&id=obsei\&name=obsei\&description=Privacy-first%20Voice%20of%20Customer) | Or `goose configure`, **Add Extension**, **Command-line Extension** with the command above | | ChatGPT | [Secure MCP Tunnel](#chatgpt) | Private use only | ## JSON config [Section titled “JSON config”](#json-config) Most tools take the same `mcpServers` block: ```json { "mcpServers": { "obsei": { "command": "uvx", "args": ["--from", "obsei>=1.0", "obsei", "mcp"], "env": { "OBSEI_CONFIG": "/path/to/obsei.yaml", "OBSEI_DB_KEY": "..." } } } } ``` VS Code’s `.vscode/mcp.json` uses `servers` in place of `mcpServers`. ## Codex [Section titled “Codex”](#codex) ```bash codex mcp add obsei --env OBSEI_CONFIG=/path/to/obsei.yaml -- uvx --from "obsei>=1.0" obsei mcp ``` Then raise the startup timeout in `~/.codex/config.toml`, and forward the database key from your environment rather than writing it to the file: ```toml [mcp_servers.obsei] command = "uvx" args = ["--from", "obsei>=1.0", "obsei", "mcp"] env = { OBSEI_CONFIG = "/path/to/obsei.yaml" } env_vars = ["OBSEI_DB_KEY"] startup_timeout_sec = 60 ``` ## Zed [Section titled “Zed”](#zed) In `settings.json`: ```json { "context_servers": { "obsei": { "command": "uvx", "args": ["--from", "obsei>=1.0", "obsei", "mcp"], "env": { "OBSEI_CONFIG": "/path/to/obsei.yaml" } } } } ``` ## ChatGPT [Section titled “ChatGPT”](#chatgpt) ChatGPT does not run local stdio servers. Its connectors need a public HTTPS MCP server with OAuth 2.1 (or no authentication) and reject static bearer tokens, so `obsei serve` with `OBSEI_API_TOKEN` cannot be added. obsei does not provide an OAuth server yet. For private use in your own workspace, OpenAI’s [Secure MCP Tunnel](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) wraps `obsei mcp` with an outbound-only connection. Create a tunnel in [Platform tunnel settings](https://platform.openai.com/settings/organization/tunnels), download `tunnel-client`, then: ```bash export CONTROL_PLANE_API_KEY="sk-..." tunnel-client init --sample sample_mcp_stdio_local --profile obsei \ --tunnel-id tunnel_... --mcp-command 'uvx --from "obsei>=1.0" obsei mcp' tunnel-client run --profile obsei ``` In ChatGPT, open **Plugins**, select **+**, **Add custom MCP server**, choose **Tunnel** under **Connection** and pick the tunnel. Keep `tunnel-client run` running while you use it. Records leave your machine for OpenAI as each tool returns them, already redacted. ## Remote MCP [Section titled “Remote MCP”](#remote-mcp) For tools that connect over HTTP with a header, `obsei serve` serves the same tools at `/mcp`; see [MCP and agents](/guides/mcp/#remote-mcp). # Alerts > Theme spikes, new themes, weekly digests, the review queue and pipeline health, sent to Slack, email, Teams or a webhook. Sinks deliver records one by one as they arrive. Alerts work on aggregates instead: a theme that grows faster than usual, a theme that appeared this week, a weekly digest, a review queue that needs a human, or a pipeline that stopped working. obsei keeps alert state in the store, so each alert fires once per cooldown instead of on every run. ```yaml alerts: - name: spikes kind: theme_spike owner: "@mobile-oncall" # optional, shown in the message sensitivity: 2.0 # z-score (default 2.0) min_count: 5 # records this week (default 5) cooldown_hours: 168 # default for spikes: once a week per theme to: - type: slack config: {webhook_url_env: SLACK_ALERTS_URL} ``` | `kind` | Fires when | Key | Options | | ----------------- | ------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------------ | | `theme_spike` | a theme’s last 7 days are well above its usual week | theme id | `sensitivity`, `min_count`, `quotes` | | `new_theme` | a theme whose records all arrived in the last 7 days reaches `k_anonymity` people; once per theme | theme id | `quotes` | | `digest` | every `period_days` (default 7): record count and the `top` (default 5) themes of the period | `digest` | `period_days`, `top` | | `review_queue` | at least `min_pending` (default 10) records from the last 7 days were marked for review | `review` | `min_pending` | | `pipeline_health` | a run fails; with `stale_hours`, also a source that fetched nothing for that long | pipeline, or `pipeline/source` | `stale_hours` | `to` lists the sinks that deliver the alert, written like pipeline sinks (`type`, `config` and an optional `key`, needed when two have the same type). They are separate from pipeline sinks, so an alerts channel never receives individual records. `webhook`, `slack`, `notify` and the `teams` plugin deliver alerts; any other sink type is a configuration error. Sink options that filter records (`when`, `max_rating`, …) do not apply to alerts. ## When alerts are checked [Section titled “When alerts are checked”](#when-alerts-are-checked) * `obsei alerts check` checks every alert now; run it from cron, or add `--dry-run` to see what would fire without sending or recording anything. * `obsei run` and `obsei themes` check alerts after they finish. * `obsei serve` checks them after each scheduled run (and after `themes.auto` updates themes). Theme alerts read themes as they were last updated, so keep themes current with `themes.auto: true` or `obsei themes`. ## Spike statistics [Section titled “Spike statistics”](#spike-statistics) Themes keep weekly counts for the last 8 weeks. A theme spikes when this week (the last 7 days) stands out from the 7 weeks before: ```text z = (this week - mean of the 7 weeks before) / max(standard deviation, 1) ``` It fires when `z >= sensitivity`, this week has at least `min_count` records, and the theme is also rising as Studio shows it: at least 3 records and at least double the week before. The standard deviation is never taken below 1, so for a theme with a flat history `z` is simply how many records above its usual week it is. The rising rule is computed by the server and returned as `rising` by Studio, the MCP `list_themes` tool and `obsei themes`. Low volume means few alerts. With 20 records a week, a theme rarely reaches `min_count`, and that is deliberate: a jump from 1 to 3 is noise. Lower `min_count` or `sensitivity` only if your volume is steady. ## Privacy [Section titled “Privacy”](#privacy) Alerts only read k-anonymous theme summaries: a theme from fewer than `themes.k_anonymity` people (default 5) is never reported, however fast it grows. Theme alerts carry up to `quotes` (default 3, `0` for none) representative quotes, as stored, so redacted, and never authors or pseudonyms. Digests, review queue and health alerts carry counts only. ## Cooldown, snooze and ack [Section titled “Cooldown, snooze and ack”](#cooldown-snooze-and-ack) Each alert fires at most once per key per `cooldown_hours` while its condition holds. The default is 168 (a week, the spike window) for `theme_spike`, so a spike that lasts all week alerts once, and 24 for the review queue and pipeline health. A digest fires once per `period_days` and a new theme once ever. ```bash obsei alerts list # last fired, snoozed and acked keys obsei alerts snooze spikes thm_3f2a9c1b --hours 72 # silence one key for a while obsei alerts ack spikes thm_3f2a9c1b --by ana # acknowledged: silent for 7 days ``` An ack silences that key for 7 days, the length of the spike window, and records who acknowledged it. If the theme still spikes after that, the alert fires again. ## Examples [Section titled “Examples”](#examples) A weekly digest by email through [Apprise](/guides/sinks/#notifications): ```yaml alerts: - name: weekly kind: digest period_days: 7 top: 5 to: - type: notify config: {urls_env: DIGEST_EMAIL} # mailtos://user:app_password@gmail.com?to=voc@acme.com ``` The review queue to Microsoft Teams with the [`teams` plugin](/reference/plugins/) (`plugins: [teams]`): ```yaml alerts: - name: review kind: review_queue min_pending: 10 owner: Support leads to: - type: teams config: {webhook_url_env: TEAMS_REVIEW_URL} ``` Everything to n8n through a signed [webhook](/guides/sinks/#webhooks): ```yaml alerts: - name: new-themes kind: new_theme to: [{type: webhook, config: {url: "https://n8n.acme.com/webhook/obsei", secret_env: N8N_SECRET}}] - name: health kind: pipeline_health stale_hours: 48 to: [{type: webhook, config: {url: "https://n8n.acme.com/webhook/obsei", secret_env: N8N_SECRET}}] ``` Webhook alerts are CloudEvents like record batches, with these `type`s, and are logged in the `deliveries` table with pipeline `alerts/`: | `kind` | Event `type` | | ----------------- | ----------------------- | | `theme_spike` | `obsei.theme.spiking` | | `new_theme` | `obsei.theme.new` | | `digest` | `obsei.digest` | | `review_queue` | `obsei.review.pending` | | `pipeline_health` | `obsei.pipeline.failed` | `data` holds the alert: ```json { "alert": "spikes", "kind": "theme_spike", "key": "thm_3f2a9c1b", "title": "Theme spiking: Can't log in", "lines": ["15 records in the last 7 days against 2.4 a week over the 7 weeks before (z = 11.2)", "Sources: appstore 9, playstore 6", "Owner: @mobile-oncall"], "count": 15, "baseline": 2.43, "theme": "thm_3f2a9c1b", "quotes": ["Since the update I cannot log in, it says is unknown"], "owner": "@mobile-oncall" } ``` # Automation tools > Receive obsei's webhook sink in n8n, Activepieces or Node-RED, and call obsei's MCP server from n8n. Workflow tools can sit on either side of obsei: they receive redacted feedback from the `webhook` sink, or they query stored feedback through the MCP server. obsei does not bundle or depend on any of them. ## Receiving the webhook sink [Section titled “Receiving the webhook sink”](#receiving-the-webhook-sink) The `webhook` sink sends a `POST` with a JSON body to your URL for each batch of new or changed records, after redaction and enrichment. Authors are left out unless you change `exclude`. To send only some records, give the sink a `key` and pick it with a [route](/guides/routing/). ```yaml sinks: - type: webhook config: url: http://n8n.internal:5678/webhook/obsei secret_env: OBSEI_WEBHOOK_SECRET ``` With `secret_env` set, each request carries a timestamp and an HMAC-SHA256 signature; the headers and the signed string are in [Sinks](/guides/sinks/). In every tool below: * verify the signature with the same secret before acting on the payload, and reject requests whose timestamp is more than a few minutes old; * compute the HMAC over the raw request body, byte for byte. JSON that was parsed and serialized again will not match; * deduplicate on record ids, since a record is sent again when it changes. A self-hosted tool on `localhost`, a private IP or an `.internal` host works in the default `air_gapped` egress mode. A public URL, such as a cloud workspace, needs `mode: private` with the host in `allowed_hosts` (see [Models](/guides/models/)). ### n8n [Section titled “n8n”](#n8n) 1. Add a **Webhook** node with method `POST`, and turn on the option to keep the raw body. 2. Add a **Crypto** node (HMAC, SHA256, hex) over the signed string built from the raw body, with the secret stored as an n8n credential or environment variable. 3. Add an **IF** node that compares the result with the signature header and stops on a mismatch. 4. Continue with your workflow, e.g. a **Split Out** node on the records list. n8n is licensed under the [Sustainable Use License](https://docs.n8n.io/sustainable-use-license/), which is not an open source license. Check that it fits how you plan to use it. ### Activepieces [Section titled “Activepieces”](#activepieces) 1. Start a flow with the **Webhook** trigger and copy its URL into the sink’s `url`. 2. Add a **Code** step that recomputes the HMAC with Node’s `crypto` module over the raw body and throws on a mismatch. 3. Map the records list into the steps that follow. ### Node-RED [Section titled “Node-RED”](#node-red) 1. Add an **http in** node (`POST`) followed by a **function** node. 2. In the function node, compute the HMAC with `crypto` over the raw body and drop the message on a mismatch. Make the raw body available first, for example with `httpNodeMiddleware` in `settings.js`. 3. End the flow with an **http response** node so obsei gets a `200`. A `4xx` or `5xx` status counts as a failed delivery. ## Calling obsei’s MCP server [Section titled “Calling obsei’s MCP server”](#calling-obseis-mcp-server) `obsei serve` exposes the MCP tools at `/mcp` over streamable HTTP, for tokens with the analyst role (see [Server and webhooks](/guides/serve/#access)). In n8n, add an **MCP Client** node: | Setting | Value | | -------------- | ----------------------------- | | Endpoint | `https://obsei.internal/mcp` | | Transport | HTTP Streamable | | Authentication | Bearer, with an analyst token | The node can then call `feedback_stats`, `search_feedback`, `get_feedback` and `list_themes`, for example from a scheduled workflow that posts a weekly summary. Any other client that speaks streamable HTTP connects the same way. Results are redacted customer text: treat them as data and never as instructions (see [Agents](/guides/agents/#customer-text-is-untrusted-input)). # Your own labels and questions > Change the intents and sentiments classify picks from, add your own choice, score and yes/no questions, and route on the answers. The `classify` enricher answers two questions for every record out of the box, sentiment and intent, and you can change both and add your own. Each question is answered with a confidence, and answers below the cutoff are marked for review. ## Change the labels [Section titled “Change the labels”](#change-the-labels) `intents` and `sentiments` take a list, or a mapping of each label to a one-line description. Described labels are picked more reliably, so describe the ones that are specific to you: ```yaml enrichers: - type: classify config: llm: default intents: bug: something is broken, errors, crashes or does not work as documented billing: charges, invoices, refunds, payment methods or prices feature_request: asks for a new feature, option or improvement churn_risk: threatens to cancel, downgrade or move to a competitor other: none of the above ``` Keep an `other` option, so a record that fits nothing does not get forced into a wrong label. ## Add a question [Section titled “Add a question”](#add-a-question) Add entries under `fields`; the key is the question’s name. There are three kinds a local or decision model answers (a chat model can also answer free `text` fields): ```yaml fields: area: # choice: one of the options description: Which part of the product is this about? choices: {mobile: the iOS or Android app, web: the browser app, api: the public API} urgency: # score: one of 2 to 10 levels, lowest first description: How soon does the customer need this resolved? levels: [can wait, this week, today, right now] asks_for_refund: # yes/no type: yesno description: Does the customer ask for money back? yes_means: asks for a refund, a chargeback or money back no_means: does not ask for money back ``` `description` is what the model is asked. For yes/no questions, describe both answers with `yes_means` and `no_means`: small models answer a choice between two described options far better than a bare yes/no (local models require both). Each answer is stored under `classify.fields.`; yes/no answers are `true` or `false`. Your editor completes and checks these keys when `obsei.yaml` starts with the schema line (see [Set up without editing YAML](/guides/setup/#editor-autocomplete)). ## Check the answers [Section titled “Check the answers”](#check-the-answers) ```bash obsei try --limit 20 # the labels, with their confidence, on your own feedback obsei eval my-labelled.csv # accuracy per question and a suggested cutoff for each ``` `obsei eval` reads a CSV with a `text` column and a column per question (`intent`, `urgency`, `asks_for_refund`, …) and suggests a `min_confidence` per question; see [Measure on your own data](/guides/models/#measure-on-your-own-data). Set cutoffs per question: ```yaml min_confidence: {intent: 0.6, urgency: 0.5, asks_for_refund: 0.7} ``` ## Route on the answers [Section titled “Route on the answers”](#route-on-the-answers) Sinks filter with `when`, and ordered `route:` rules send each record to one set of sinks: ```yaml sinks: - {key: oncall, type: slack, config: {webhook_url_env: ONCALL_WEBHOOK_URL}} - {key: billing, type: slack, config: {webhook_url_env: BILLING_WEBHOOK_URL}} - {key: lake, type: parquet, config: {directory: lake}} route: - review: [lake] # uncertain answers: no ticket, no page - name: urgent-bugs when: {classify.intent: [bug], classify.fields.urgency: {gte: today}} sinks: [oncall] - name: refunds when: {classify.fields.asks_for_refund: ["true"], classify.intent: {is: billing, min_confidence: 0.7}} sinks: [billing] - default: [lake] ``` `gte` and `lte` compare score levels in the order you listed them, and obsei checks on load that the levels a route names are levels of that field. Add the two Slack sinks with `obsei add slack --key oncall` and `obsei add slack --key billing`; each gets its own webhook secret. [Routing](/guides/routing/) lists every condition. After changing questions or labels, `obsei reprocess` relabels the records already stored and keeps human corrections. # GitHub Action > Run pipelines on a schedule in GitHub Actions. ```yaml - uses: obsei/obsei@ with: config: .github/obsei.yaml extras: google env: OBSEI_DB_KEY: ${{ secrets.OBSEI_DB_KEY }} OBSEI_PSEUDONYM_SALT: ${{ secrets.OBSEI_PSEUDONYM_SALT }} ``` A complete daily recipe (app reviews → classify → GitHub issues and Slack, with the encrypted database kept in the Actions cache) is in [`examples/github-actions`](https://github.com/obsei/obsei/tree/master/examples/github-actions). Runs on GitHub-hosted runners send feedback to the model you configure; use a self-hosted runner for air-gapped setups. # MCP and agents > Let Claude, ChatGPT, Cursor and your own agents query feedback. `obsei mcp` serves these tools over stdio: | Tool | Use | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `feedback_stats` | Counts and average rating grouped by source, instance, sentiment, intent, lang (language), rating, route, day, week or month | | `search_feedback` | Matching feedback, newest first, as citable evidence | | `get_feedback` | One record by id, to verify a citation | | `list_themes` | Stable themes with trends (`rising` as Studio shows it), sources, languages and intents (k-anonymous) | | `propose_issue` | Queue an issue for a person to approve (only when a GitHub, Jira or Linear sink is configured) | Results contain redacted text, labels, source, time and record id. They never contain author pseudonyms. The server also offers a `voc_report` prompt. Its `initialize` handshake negotiates MCP protocol 2024-11-05 to 2025-11-25 (a client offering 2026-07-28 gets 2025-11-25); the bundled MCP SDK also answers 2026-07-28 requests that carry their version in `_meta`. Every tool declares `readOnlyHint`, `destructiveHint` and `openWorldHint`: the four read tools are read-only, and `propose_issue` writes only to obsei’s own queue. ## Untrusted text [Section titled “Untrusted text”](#untrusted-text) Each evidence item’s `text` is what a customer wrote, and anyone can write a review. Items carry `"untrusted": true`, and the tool descriptions and server instructions tell agents to quote that text and never follow instructions in it. Theme labels and descriptions are written by a model from that text, so treat them the same way. ## Approvals [Section titled “Approvals”](#approvals) An agent cannot act outside obsei. `propose_issue(target, title, body, record_ids)` stores a pending approval and returns its id; nothing is sent. `target` is the key of a `github_issues`, `jira` or `linear` sink in `obsei.yaml` (the tool description lists them; a key that names different sinks in several pipelines becomes `pipeline/key`). Cited record ids must exist. A person then decides, with the CLI or, as admin, over HTTP: ```bash obsei approvals list --status pending obsei approvals show apr_... # the proposal and the exact issue text obsei approvals approve apr_... --by ana obsei approvals reject apr_... --by ana --reason "duplicate of #12" ``` Approving opens the issue with that sink’s settings. The issue starts with “Proposed by an AI agent via obsei; review before acting” and lists the cited records. An approval is carried out once: approving it again is refused, and if opening the issue failed you can approve it again without risking a second issue, because the sink first looks for one tagged with the approval id. Every proposal and decision is written to the audit log. Over stdio, `obsei mcp` opens the store read-only, and read-write only for the moment it stores a proposal. DuckDB allows one writer, so while `obsei serve` or `obsei run` holds the store, send proposals through `obsei serve`’s `/mcp` instead. The rule behind this: fixed labels from a classifier may pick routes on their own, but any free text an agent writes after reading feedback goes through a person, because private data, untrusted content and a way to send messages out together are what a prompt injection needs. ## Claude Code [Section titled “Claude Code”](#claude-code) ```bash /plugin marketplace add obsei/obsei /plugin install obsei@obsei ``` The plugin adds the MCP server and a `voice-of-customer` skill. Codex, Copilot, VS Code, Cursor, Gemini and other tools: see [Use obsei from your AI tool](/guides/ai-tools/). ## Claude Desktop and other clients [Section titled “Claude Desktop and other clients”](#claude-desktop-and-other-clients) ```json { "mcpServers": { "obsei": { "command": "uvx", "args": ["--from", "obsei>=1.0", "obsei", "mcp"], "env": { "OBSEI_DB": "/path/to/obsei.duckdb", "OBSEI_DB_KEY": "..." } } } } ``` ## Remote MCP [Section titled “Remote MCP”](#remote-mcp) `obsei serve` exposes the same tools at `/mcp` (streamable HTTP). Set `OBSEI_API_TOKEN`; clients send `Authorization: Bearer `. Put it behind your SSO proxy for per-user access. Give agents an analyst token: proposals then record who made them (`agent:`), and only admins can approve them. # Migrate from 0.0.x > How obsei 1.0 differs from the 0.0.x toolkit, what maps to what, and how to stay on the old version. obsei 0.0.x was an “observe, analyze, inform” toolkit: Python classes you wired together in your own code. 1.0 is a rewrite with a new codebase, not an upgrade. There is no compatibility layer, and 0.0.x code will not run against it. ## Concepts [Section titled “Concepts”](#concepts) | 0.0.x | 1.0 | | ----------------------------------------------- | ------------------------------------------------------------------------ | | Observer | Source (`sources:` in a pipeline) | | Analyzer | Enricher (`enrichers:`) | | Informer | Sink (`sinks:`) | | Workflow, built and run in Python | Pipeline in `obsei.yaml`, run with `obsei run` or `obsei serve` | | Workflow store (SQL state) | Encrypted DuckDB store, which also skips records it has seen | | PII anonymizer analyzer, opt-in | Redaction and author pseudonyms at ingest, on by default | | Transformer classifiers and sentiment analyzers | `classify` with your LLM or a decision model ([Models](/guides/models/)) | Configuration is declarative: a config file can name plugins but never import arbitrary code. Run `obsei init` for a starting `obsei.yaml`, then see [Configuration](/configuration/). ## Connectors [Section titled “Connectors”](#connectors) | 0.0.x | 1.0 | | ------------------ | ------------------------------------------------------------------- | | App Store scraper | `appstore` (public review feed) or `appstoreconnect` (official API) | | Play Store scraper | `playstore` (official Android Publisher API) | | YouTube scraper | `youtube` (YouTube Data API key) | | Email | `imap` | | Pandas DataFrame | `csv` and `jsonl` sources, `parquet` sink | | HTTP informer | `webhook` sink | | Slack, Jira | `slack`, `jira` sinks | | Zendesk informer | `zendesk` is now a source; send tickets with `webhook` or a plugin | ## What’s gone [Section titled “What’s gone”](#whats-gone) * Twitter/X, Facebook, Google Maps reviews, Google News and the web crawler. * The Reddit scrapers. Reddit is a community plugin that reads RSS ([install it from Git](/guides/sources/#reddit-plugin)). * The Elasticsearch and logger informers. Use the `sql`, `parquet` or `webhook` sink, or `obsei try` to inspect records. * The translator and NER analyzers. Feedback stays in its original language, and names are found only for redaction (`obsei[names]`). Missing a connector? Write a [plugin](/reference/plugins/) for it. ## Staying on 0.0.x [Section titled “Staying on 0.0.x”](#staying-on-00x) Pin below 1.0: ```bash pip install "obsei<1" ``` A plain `pip install obsei` gives 1.0, so keep the pin. The 0.0.x code lives on the [`legacy/0.0.x`](https://github.com/obsei/obsei/tree/legacy/0.0.x) branch. # Models > A local classifier by default; decision models or any LLM when you choose, with egress control. Models are named under `llms` in `obsei.yaml`; plugins refer to them by name (`default` unless set). Each entry has an `api`: | `api` | What runs | Use it for | | -------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- | | `local` (the `obsei init` default) | zero-shot classifiers inside obsei, on the CPU | `classify` labels, routing fields and scores, with nothing to serve | | `decision` | a [decision model](#decision-models) on llama.cpp or a hosted API | the same labels from a model server; `filter` and `ask_judge` | | `openai` (default when `api` is unset) | any OpenAI-compatible chat endpoint | `ask`, theme labels, free-text fields, or `classify` with a chat model | ## Local classifier [Section titled “Local classifier”](#local-classifier) The default classifier runs in process, so feedback never leaves the machine and there is no server to start: ```bash pip install 'obsei[local]' # PyTorch, Transformers and GLiNER2 obsei models download # once, with network; --dir or OBSEI_MODELS_DIR picks the cache ``` ```yaml llms: default: api: local model: MoritzLaurer/bge-m3-zeroshot-v2.0 # the default; any zero-shot NLI model works models: {sentiment: fastino/gliner2-multi-v1} # optional: another model for one question ``` Each question is answered on its own. Every option, written as `key: description`, is scored as the hypothesis “This customer message is …” against the message text, which gives a probability per option. The answer is the most likely option, and its probability is the confidence. A score field scores each level with its question, so `scores` and `levels` work as with decision models. A `yesno` field needs `yes_means` and `no_means` and is asked as a two-option choice. Free-text fields need a chat model. Models with `gliner2` in their name run with [GLiNER2](https://github.com/fastino-ai/GLiNER2) instead of NLI. `bge-m3-zeroshot-v2.0` (MIT, 568M parameters, about 2.2 GB) labelled about 0.7 records a second on one CPU with three questions per record. Each extra field costs about as much as one of those questions. `gliner2-multi-v1` (Apache-2.0) is about five times faster and slightly better at sentiment, but it routed no Japanese message to the right team, so the example above uses it for sentiment only. `obsei models download --classifier ` (repeatable) fetches other models. In `air_gapped` mode (the default) obsei never downloads a model: it loads them from `OBSEI_MODELS_DIR`, or from a local directory given as `model`. Run `obsei models download` once on a machine with network and copy the directory over if needed. ### Benchmark [Section titled “Benchmark”](#benchmark) We labelled the 136 records of the [demo](/demo/) (8 issues in 11 languages) with three questions: intent, sentiment and team. Each model got the demo’s option descriptions, and the expected answer for each record comes from the demo issue it was written for. | Model | How it answers | Intent | Sentiment | Team | Mean | Worst ECE | Review at 0.7 | Records/s | | ------------------------------------------------ | --------------------------- | ------ | --------- | ---- | ---- | --------- | ------------- | --------- | | Julia-1 (144M) | decision API, llama.cpp | .71 | .75 | .62 | .69 | .33 | 23% | not timed | | mDeBERTa-v3-base xnli | zero-shot NLI | .46 | .65 | .76 | .62 | .31 | 96% | 0.64 | | **bge-m3-zeroshot-v2.0** (default) | zero-shot NLI | .82 | .90 | .91 | .88 | .26 | 80% | 0.69 | | gliner2-multi-v1 | GLiNER2 | .77 | .92 | .75 | .81 | .19 | 36% | 3.25 | | gliner2-base-v1 | GLiNER2, English only | .76 | .61 | .65 | .67 | .38 | 21% | 3.52 | | Qwen3-0.6B | option-letter probabilities | .74 | .75 | .86 | .78 | .21 | 43% | 0.18 | | gliner2-multi for sentiment, bge-m3 for the rest | `models: {sentiment: …}` | .82 | .95 | .91 | .90 | | 71% | 0.56 | Intent, sentiment and team are accuracy. ECE is the expected calibration error of the confidence, for the worst of the three questions (0 means a confidence of 0.8 is right 80% of the time). Review at 0.7 is the share of records with at least one answer below 0.7. The mixed row was measured through obsei’s `api: local` classifier; the others with the benchmark script, calling each model the same way. * bge-m3 was the most accurate, and right at least half the time in every language for every question. Its probabilities are spread out, so at the usual 0.7 cutoff it sends 80% of records to review; `obsei init` uses 0.4 for it, which sends 13% ([cutoffs](#choose-a-cutoff-per-question)). * Julia-1 was the least accurate and the most overconfident: few answers fall below the cutoff, but many confident answers are wrong (team .62, ECE .33). Its numbers are from the labels committed with the demo. * mDeBERTa and gliner2-base missed whole languages (0% in Japanese, Korean, Hindi or Arabic on some questions). Qwen3-0.6B was the best calibrated on team routing (ECE .05) but the slowest. Caveats: the records are synthetic, about 12 per language, and expected answers come from the demo’s issue definitions, not from human review of each message. Timings are PyTorch on one CPU, one record at a time. Qwen3 1.7B and 4B, larger decision models (Kev-4B, Clef-Flash) and GPUs were not tested. Your options and your feedback differ, so [measure on a labelled sample of your own](#measure-on-your-own-data) before a route acts on labels. `obsei eval --demo` reruns the default’s row: the gold set ships with obsei as `templates/demo-gold.csv`. ## Chat endpoints [Section titled “Chat endpoints”](#chat-endpoints) obsei talks to any OpenAI-compatible chat endpoint with structured JSON output: ```yaml llms: chat: base_url: http://localhost:11434/v1 model: qwen3:8b max_requests: 500 # optional; also api_key_env, api_key_header, timeout ask_llm: chat # ask and the Slack bot use this entry (default: default) ``` | Where | `base_url` | | ---------------------------------------- | ------------------------------------------------------------------------------ | | Ollama | `http://localhost:11434/v1` | | vLLM, llama.cpp, LM Studio | `http://your-host:8000/v1` | | Azure OpenAI | `https://.openai.azure.com/openai/v1` with `api_key_header: api-key` | | OpenAI, Mistral, Groq, OpenRouter | the provider’s `/v1` URL | | AWS Bedrock, Google Vertex AI, Anthropic | a [LiteLLM proxy](https://docs.litellm.ai) in your network | To classify with a chat model instead, point `classify` at it (`llm: chat`). A cloud model needs its own `llms` entry and must pass the [egress policy](#egress-modes): send feedback to a provider only when you have consent to do so. ## Egress modes [Section titled “Egress modes”](#egress-modes) | Mode | Model and sink endpoints allowed | | ---------------------- | ---------------------------------------------------------------------------------------------- | | `air_gapped` (default) | localhost, private and link-local IPs, `.localhost`, `.internal`, `.local`, single-label hosts | | `private` | the above plus `OBSEI_EGRESS_ALLOW` (comma-separated hosts and their subdomains) | | `hybrid` | any host | Set the mode with `OBSEI_EGRESS_MODE`, or with `egress: {mode: private, allowed_hosts: [...]}` in `obsei.yaml`, which takes precedence over the environment. A public endpoint is refused before any request is made. Local classifiers make no requests at run time; in `air_gapped` mode they also never download. ## Cost and quality [Section titled “Cost and quality”](#cost-and-quality) `max_requests` caps requests per endpoint client, so a run cannot exceed it. With `fallback_llm` on the `classify` enricher, the `llm` model labels everything and records it is unsure about go to the `fallback_llm` chat model ([cutoffs](#choose-a-cutoff-per-question)). Feedback is sent to chat models as JSON data, and the system prompt tells the model never to follow instructions inside it. ## Decision models [Section titled “Decision models”](#decision-models) A decision model does not write text: you give it the feedback and a few questions with their options, and it scores every option in one forward pass. The answer is always one of your options, with a probability for each, so there is nothing to parse and nothing to retry. Small decision models run fast on a CPU: Julia-1 answered four questions about a ticket in about 140 ms in our tests. Local classifiers give answers of the same shape, so everything below about fields, probabilities and cutoffs applies to `api: local` too. | Use a decision or local model for | Use a chat model for | | ------------------------------------------------------------------------------- | ------------------------------------------------ | | Labels from a fixed list: sentiment, intent, product area | Theme labels (short phrases you have not listed) | | Routing: which team, which queue, which sink | Ask answers and summaries | | Moderation: spam, bots, off-topic (the `filter` enricher, decision only) | Free-text `classify` fields | | Ordered scores: urgency, severity, effort | Feedback no option fits well | | Judging: is an Ask answer supported by its sources (`ask_judge`, decision only) | | obsei speaks the decision API served by llama.cpp, the OpenJev helper and hosted services. Set `url`, or `url_env` (an environment variable holding it), to the full endpoint URL your server documents ([Serving](#serving)); obsei posts to it as is: ```yaml llms: julia: api: decision url_env: OBSEI_DECISION_URL # or url: model: Julia-1 # optional; picks a model on a llama.cpp router timeout: 30 # also api_key_env, api_key_header, max_requests ``` ### Models [Section titled “Models”](#models) | Model | Size | Languages | Images | Get it | | ---------- | ----------------------- | ---------------------- | ------------------ | -------------------------------------------------------------------------- | | Julia-1 | 144M (mmBERT-small) | 50+ | no | `ggml-org/Julia-1-GGUF` (Q8_0, 168 MB) | | Laya | 421M (ModernBERT-large) | English | no | the model’s page | | Kev-4B | 4B | English | no | `ggml-org/Kev-4B-GGUF` | | lev | 4B | English | no | the model’s page | | Clef-Flash | 9B (Qwen3.5-9B) | benchmarked in English | no | `ggml-org/Clef-Flash-GGUF` (Q4_K_M 6.5 GB, Q8_0 9.7 GB) | | Clef | 27B (Qwen3.8-27B) | benchmarked in English | Transformers only | `ggml-org/Clef-GGUF` (Q4_K_M 19.2 GB, Q8_0 28.7 GB); Cloudflare Workers AI | | OpenJev | 27B | en, de, fr, hi, zh, ja | vLLM (16-bit, FP8) | `openjev/openjev` (also `-FP8`, `-GGUF`, `-MLX`) | Check each model’s page for its licence and terms. Julia-1 is the smallest multilingual decision model and the fastest per record, but in the [benchmark](#benchmark) it was less accurate than the local default. Use **Kev-4B** or **lev** for English-only feedback, and **Clef-Flash** when you have a GPU (its published benchmarks are English). A 4B or larger decision model is also what `ask_judge` needs. ### Serving [Section titled “Serving”](#serving) ```bash llama serve -hf ggml-org/Julia-1-GGUF llama serve -hf ggml-org/Clef-Flash-GGUF # a GPU helps ``` llama.cpp serves decisions at `http://127.0.0.1:8080/v1/systemone`; set `OBSEI_DECISION_URL` to it. OpenJev runs on vLLM behind its helper (`openjev/helper/shim.py`), which serves the same API on port 3000 (the same path on `http://127.0.0.1:3000`). OpenJev reads prompts up to 16,384 tokens. Clef answers all questions of a request together in one prompt (the others answer each question independently). A llama.cpp server running Clef serves only the decision endpoint, no chat, so point `fallback_llm`, `ask_llm` and `themes.labeler` at a different endpoint. Clef is also hosted on Cloudflare Workers AI. Give the run URL as `url`; obsei unwraps Cloudflare’s response envelope: ```yaml egress: mode: private allowed_hosts: [api.cloudflare.com] llms: clef: api: decision url: https://api.cloudflare.com/client/v4/accounts//ai/run/@cf/cloudflare/clef model: clef api_key_env: CLOUDFLARE_API_TOKEN # sent as a Bearer token ``` The hosted option sends redacted feedback text to Cloudflare, which is why it needs `private` egress with `api.cloudflare.com` allow-listed. A self-hosted GGUF keeps everything on your machines and works in `air_gapped` mode. ### Describe the options [Section titled “Describe the options”](#describe-the-options) Bare labels route worse than described ones. `sentiments`, `intents` and `choices` accept a list or a mapping of option to a one-line description; obsei ships descriptions for the built-in sentiments and intents. ```yaml - type: classify config: llm: default intents: bug: something is broken, errors, crashes or does not work as documented billing: charges, invoices, refunds, payment methods or prices churn_risk: threatens to cancel, downgrade or move to a competitor other: none of the above fields: area: # choice description: Which part of the product is this about? choices: {mobile: iOS or Android app, web: the browser app, api: the public API} urgency: # score: levels lowest first (2 to 10) description: How soon does the customer need this resolved? levels: [can wait, this week, today, right now] asks_for_refund: # yes/no type: yesno description: Does the customer ask for money back? yes_means: asks for a refund, a chargeback or money back no_means: does not ask for money back ``` Each field is one question; a decision model gets all questions for a record in one request. A `yesno` field with `yes_means` and `no_means` is asked as a choice between two described options; small models answer that far more reliably than a bare yes/no question, so describe both sides (local models require it). Free-text fields need a chat model. Language is taken from the record. The stored `classify` value keeps the usual `sentiment`, `intent`, `language`, `confidence` and `fields` (yes/no fields are `true` or `false`), plus `probabilities` per question, `scores` (the expected level of each score field, e.g. 2.28 between “today” and “right now”), `levels` of each score field, `confidences`, and `review` with the `uncertain` questions. Sinks filter on nested and yes/no fields: ```yaml when: {classify.intent: [bug], classify.fields.urgency: [today, right now]} when: {classify.fields.asks_for_refund: ["true"]} when: {classify.review: ["true"]} ``` Conditions can also test the stored numbers, e.g. `{classify.intent: {is: bug, min_confidence: 0.8}}` or `{classify.fields.urgency: {gte: today}}`, and ordered `route:` rules send each record to one set of sinks. See [Routing](/guides/routing/). ### Choose a cutoff per question [Section titled “Choose a cutoff per question”](#choose-a-cutoff-per-question) Each answer has a confidence. For a local model it is the top probability. For a decision model it is the server’s confidence, which is 0 when every option is equally likely. For a yes/no it is the larger of p and 1 - p. These probabilities are not calibrated for your data: in the [benchmark](#benchmark) one model was overconfident and another underconfident on the same question. Check cutoffs on a labelled sample of your own feedback ([`obsei eval`](#measure-on-your-own-data) suggests one per question) and set them per question: ```yaml - type: classify config: llm: default fallback_llm: chat # a chat model on another endpoint threshold: 0.6 # cutoff for every answer min_confidence: # per-question overrides team: 0.45 # options that overlap spread the probability urgency: 0.3 # score confidence runs lower than choice confidence ``` A record with any answer below its cutoff goes to `fallback_llm`, which labels the whole record. Without a fallback, or when the fallback fails, the labels are kept with `review: true`. The cutoff trades review load against accuracy. For the local default (bge-m3) on the 136 demo records: | Cutoff | Records marked for review | Accuracy of kept answers (intent / sentiment / team) | | ------ | ------------------------- | ---------------------------------------------------- | | 0.3 | 1% | .82 / .90 / .92 | | 0.4 | 13% | .83 / .90 / .95 | | 0.5 | 41% | .88 / .94 / .98 | | 0.7 | 80% | .97 / .98 / 1.00 | `obsei init` sets `threshold: 0.4` for the local classifier; the built-in default stays 0.7, which suits decision models such as Julia-1. These numbers hold for this model and these options only: cutoffs depend on the model, so tune them per question on labelled feedback of your own: `obsei eval` suggests them ([Measure on your own data](#measure-on-your-own-data)). ### Known limits [Section titled “Known limits”](#known-limits) Small models make mistakes, so measure them on your own feedback before a route acts on their labels. * The [demo](/demo/) shows labels from Julia-1 (144M parameters, on a CPU) as they came out of the model. At the demo’s cutoffs it marks 67 of 136 records (49%) for review, and some labels are wrong: 13 of 29 login complaints are routed to the `design` team. * Start with a `review` route and a high `min_confidence`, read a sample of what goes to review, then lower the cutoffs where the model is reliably right. * Clef-Flash currently aborts in the CPU build of llama.cpp (a GPU build or Cloudflare Workers AI may work), so it is not in the benchmark. ### Egress and air-gapped use [Section titled “Egress and air-gapped use”](#egress-and-air-gapped-use) A decision endpoint follows the same egress policy as chat endpoints: the URL is checked before any request, and every request is checked again on the wire. Julia-1 and the other GGUF models run under llama.cpp inside your network, so a decision pipeline works fully `air_gapped`; only hosted APIs (Jev, Workers AI) need `private` mode with their host allow-listed. ## Measure on your own data [Section titled “Measure on your own data”](#measure-on-your-own-data) Label a few hundred messages of your own feedback in a CSV: a `text` column, an optional `lang` column, and one column per question, named as in `min_confidence`: `sentiment`, `intent`, or the field name (`team`, `urgency`; yes/no fields take `true` or `false`). Answers must be options of your `classify` config; empty cells are not scored. ```csv text,lang,intent,sentiment,team I can't log in since the update,en,bug,negative,engineering Fui cobrado duas vezes este mês,pt,complaint,negative,payments ``` `obsei eval gold.csv` redacts each row as at ingest, runs the `classify` enricher of the first pipeline that has one (`-p` picks another) and compares its answers with yours. Nothing is stored or delivered; a model endpoint sees the redacted text as in a run. On the demo gold set with the local default (`obsei eval --demo`, about 3.5 minutes on a laptop CPU): ```text 136 records, model MoritzLaurer/bge-m3-zeroshot-v2.0, 13% marked for review question n accuracy macro F1 ECE Brier cutoff review kept suggest review worst language sentiment 136 0.90 0.87 0.06 0.07 0.40 0% 0.90 0.44 1% ko 0.50 intent 136 0.82 0.81 0.13 0.11 0.40 1% 0.83 0.51 12% es 0.60 team 136 0.91 0.92 0.26 0.15 0.40 13% 0.95 0.00 0% de 0.75 most common mistakes (expected -> answered): sentiment negative -> neutral (12), negative -> positive (2) intent praise -> bug (12), bug -> complaint (6), feature_request -> bug (6) team payments -> engineering (6), design -> engineering (3), engineering -> logistics (2) ``` * **cutoff, review, kept:** the configured cutoff of each question, the share of answers below it, and the accuracy of the answers at or above it. * **suggest:** the lowest cutoff whose kept answers are right at least 90% of the time (`--target-precision`), and the share of answers it sends to review. Set it under `min_confidence`; `-` means no cutoff reaches the target. * **ECE and Brier** measure how well the confidence matches how often answers are right (0 is perfect). The top line counts records with any answer below its cutoff. `--json` prints the full report. `obsei eval` exits non-zero only on errors, or with `--min-accuracy 0.8` when a question scores lower, so it can gate a model or config change in CI. The demo gold set ships with obsei as `templates/demo-gold.csv`; its expected answers come from the demo issue each record was written for. ### Correct labels [Section titled “Correct labels”](#correct-labels) Fix wrong answers with a CSV of record ids (from Studio, MCP or `obsei export`) and a column per question: ```bash obsei correct labels.csv # id,intent,team ``` A correction replaces the model’s answer inside the record’s `classify` labels, with confidence 1, removes it from `uncertain` (so `review` clears once every uncertain answer is corrected), and lists the question under `corrected`. Routes, MCP tools and Studio read corrected labels like any other; records already delivered are not sent again. Each run is recorded in `obsei audit`. `obsei eval --from-store` uses every corrected record as the gold set, so the labels your reviewers fix become the test set for the next model or cutoff. ### Relabel stored feedback [Section titled “Relabel stored feedback”](#relabel-stored-feedback) After changing the model, options, fields or cutoffs, relabel what is already stored: ```bash obsei reprocess --dry-run # count what would change obsei reprocess --since 2026-09-01 # relabel records created since then ``` Every stored record with `classify` labels is labelled again by the current config; human corrections are kept. The report counts changed answers per question with the most common changes (`intent 14 changed: praise -> bug (9), ...`). Records are updated in place and the run is audited; sinks are not called and routes are not re-run. DuckDB allows one writer, so stop `obsei serve` before `reprocess` or `correct`. A practical loop: start with a `review` route, correct what lands there, run `obsei eval --from-store`, adjust options and cutoffs, then `obsei reprocess`. # Routing > Send each record to the right sinks with filters and ordered routes, using the labels, confidences and scores decision models store. Every sink can filter what it receives with `when`. A pipeline can also list ordered `route:` rules that pick, for each record, which named sinks get it. Both use the same conditions, read from the enrichments a record carries (usually `classify`, and `filter` for spam). ## Filters and routes [Section titled “Filters and routes”](#filters-and-routes) **Filters fan out.** Each sink checks every record against its own `when` and `max_rating`. One record can reach several sinks, or none. This is how pipelines without `route:` work. **Routes pick one rule.** Rules are checked in order and the first match wins; the record goes to that rule’s sinks only. Use routes when destinations should not overlap: an urgent bug pages on-call and opens a Jira issue, but does not also land in the billing channel. **`default`** is the last rule: records no earlier rule matched go to its sinks. Without it they go to no routed sink. **`review`** is a shortcut for records still flagged for review. A decision model marks a record with `review: true` when an answer falls below its cutoff; with a `fallback_llm` the chat model relabels it and the flag goes away, so `review` catches only what the fallback could not settle. Put it first so uncertain labels never open tickets. ### Which sinks routes control [Section titled “Which sinks routes control”](#which-sinks-routes-control) Sinks are named by `key`; without one a sink’s key is its type. Routes refer to keys. * A sink named by any route gets only the records routed to it, then still applies its own `when` and `max_rating`. Give it `when: {}` when the route should decide alone (`jira`, `github_issues` and `linear` otherwise keep their default `classify.intent: [bug, feature_request]`). * A sink no route names keeps today’s behaviour: every record, through its own `when`. An audit webhook or a warehouse table can sit next to routes this way. * In a pipeline with `route:`, keys must be unique: give two sinks of the same type their own `key`. Without `route:`, unkeyed sinks of one type still load as before. Each record is stored with a `route` enrichment, `{"rule": "urgent-bugs", "sinks": ["jira", "oncall"]}`, or `{"rule": "unrouted", "sinks": []}` when nothing matched. Parquet, SQL and webhook sinks export it with the other enrichments, `feedback_stats` and the store group by `route`, and Studio shows a **Routes** chart once routed records exist (groups under the k-anonymity threshold are hidden, as for every chart). ## Conditions [Section titled “Conditions”](#conditions) A condition maps a dotted path to a test. `classify.intent` and `classify.sentiment` are labels, `classify.fields.` a field you defined, `filter.match` the spam filter’s answer. All conditions in one `when` must hold. | Condition | Matches | | -------------------------------------------------------------- | -------------------------------------------------------------------- | | `classify.intent: [bug, churn_risk]` | the value is one of these (yes/no values are `"true"` and `"false"`) | | `classify.intent: {is: bug}` | the same; `is` takes one value or a list | | `classify.intent: {is: bug, min_confidence: 0.8}` | and the answer’s confidence is at least 0.8 | | `classify.fields.urgency: {gte: today}` | a score field at or above a level, by level order | | `classify.fields.urgency: {lte: this week}` | at or below a level | | `classify.fields.urgency: {gte: 2.5}` | the expected score (0 = lowest level, may be fractional) | | `classify.fields.asks_for_refund: {is: true}` | a yes/no field answered yes | | `classify.fields.asks_for_refund: {min_probability: 0.7}` | the probability of yes is at least 0.7 | | `classify.intent: {is: [bug, question], min_probability: 0.9}` | the listed options together have at least 0.9 | | `filter.match: {min_probability: 0.9}` | the filter’s probability of yes | | `review: true` | an enricher flagged the record for review (`false`: not flagged) | | `max_rating: 2` | rated 2 or lower; unrated records do not match | Where the numbers come from: * **`min_confidence`** uses the per-question confidence a decision model stores (`classify.confidences`). Chat-model results have one overall confidence, which is used instead. * **`min_probability`** uses the stored per-question probabilities. For chat-model results it is approximated from the overall confidence: the confidence when the value is a target, one minus it otherwise. * **`gte` / `lte` with a level** compare positions in the field’s `levels`, which `classify` stores with each result; for records stored without them, routes use the levels in the pipeline’s `classify` config. **With a number** they compare the decision model’s expected score, or the level’s position for chat-model results. A record without the enrichment, or without the field, matches no condition on it. Conditions are checked when the config loads. Errors name the pipeline and the sink or rule, for example `pipeline 'tickets', route 2 (urgent-bugs): when.classify.fields.urgency: 'soon' is not one of the levels ['can wait', 'this week', 'today', 'right now']`. Level names are checked against the pipeline’s `classify` fields; `gte` and `lte` with a level need a score field. The same conditions work in a sink’s `when`, so a single sink can say `when: {classify.intent: {is: bug, min_confidence: 0.8}, review: false}`. Existing lists of values keep working unchanged. ## Route rules [Section titled “Route rules”](#route-rules) ```yaml route: - review: [review] # records still flagged for review - name: urgent-bugs # optional; shown in analytics when: {classify.intent: [bug], classify.fields.urgency: {gte: today}} sinks: [jira, oncall] - when: {classify.intent: [billing]} # unnamed: called "billing" after its sinks sinks: [billing] - default: [lake] # must be last ``` Each rule is exactly one of `when` with `sinks`, `review` or `default`. `name` is optional; an unnamed rule is called after its sinks joined with `+` (`jira+oncall`), `review` and `default` after themselves. Names must be unique; `unrouted` is reserved. `sinks: []` routes matching records nowhere, which keeps them out of the default. ## Full example [Section titled “Full example”](#full-example) Support tickets labelled by a decision model, with a chat model for hard cases. Uncertain tickets go to a review channel, urgent bugs open a Jira issue and page on-call, billing and refunds go to the billing channel, and everything else lands in Parquet. An audit webhook, named by no route, receives every ticket. ```yaml pipelines: - name: tickets sources: - key: zendesk type: zendesk config: {subdomain: acme} enrichers: - type: classify config: llm: julia fallback_llm: ollama threshold: 0.6 intents: [bug, billing, how_to, feature_request, churn_risk, praise, other] fields: urgency: description: How soon does the customer need this resolved? levels: [can wait, this week, today, right now] asks_for_refund: type: yesno description: Does the customer ask for money back? yes_means: asks for a refund, a chargeback or money back no_means: does not ask for money back sinks: - key: jira type: jira config: {base_url: https://acme.atlassian.net, project_key: SUP, when: {}} - key: oncall type: slack config: {webhook_url_env: ONCALL_SLACK_WEBHOOK_URL} - key: billing type: slack config: {webhook_url_env: BILLING_SLACK_WEBHOOK_URL} - key: review type: slack config: {webhook_url_env: REVIEW_SLACK_WEBHOOK_URL} - key: lake type: parquet config: {directory: support-lake} - key: audit # no route names it: gets every ticket type: webhook config: {url: https://audit.internal/obsei} route: - review: [review] - name: urgent-bugs when: classify.intent: {is: bug, min_confidence: 0.8} classify.fields.urgency: {gte: today} sinks: [jira, oncall] - name: billing when: {classify.intent: [billing]} sinks: [billing] - name: refunds when: {classify.fields.asks_for_refund: {min_probability: 0.7}} sinks: [billing] - default: [lake] ``` A ticket that says “Checkout is down, none of our customers can pay” with intent `bug` at 0.95 and urgency `right now` matches `urgent-bugs`: one Jira issue, one on-call message, nothing in billing or Parquet, and a copy to the audit webhook. A “how do I export invoices” question matches nothing until `default` and lands in Parquet with `route.rule: default`. `obsei run` reports deliveries per sink key (`sent jira=1, oncall=1, lake=12, ...`), and `feedback_stats(group_by="route")` counts records per rule. The complete config, with sources, the spam filter and a local sample, is in [examples/decision-routing.yaml](https://github.com/obsei/obsei/blob/master/examples/decision-routing.yaml). # Server and webhooks > Run obsei as a service with webhook intake and remote MCP. ```bash export OBSEI_API_TOKEN="$(openssl rand -hex 32)" obsei serve --host 0.0.0.0 --port 8765 ``` | Path | Auth | Purpose | | ---------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /healthz` | none | liveness: `{"status": "ok"}`, or `503 {"status": "degraded"}` if the scheduler stopped | | `POST /ingest/{pipeline}/{source}` | Standard Webhooks signature | push feedback into a `webhook` source (1 MB max) | | `POST /slack/commands` | Slack request signature | the `/obsei` slash command (404 without `SLACK_SIGNING_SECRET`) | | `GET /` | none | redirects to `/studio/` | | `GET /studio/` | none (static files) | [Studio](/guides/studio/); its data comes from `/api/*` with the caller’s token | | `GET /api/snapshot` | viewer | k-anonymous overview, themes and knowledge graph, without quotes | | `GET /api/themes/{id}` | analyst | redacted evidence for one theme (empty below `k_anonymity`) | | `POST /api/ask` | analyst | `{"question": "..."}` returns `{"answer": "...", "grounded": 0.93, "possibly_unsupported": false}` with record-id citations; `grounded` is null without `ask_judge` | | `/mcp` | analyst | MCP over streamable HTTP | | `GET /api/runs` | admin | schedule and last run of each scheduled pipeline, with errors and warnings | | `GET /api/deliveries` | admin | the latest 100 [webhook deliveries](/guides/sinks/#webhooks): event id, sink, host, attempts, status, error | | `GET /api/approvals` | admin | the latest 100 [agent proposals](/guides/mcp/#approvals) and their status | | `POST /api/approvals/{id}/approve` | admin | open the proposed issue (once) and return the approval | | `POST /api/approvals/{id}/reject` | admin | optional `{"reason": "..."}`; nothing is sent | Any other path needs admin. Roles are checked only when `OBSEI_API_TOKEN` or `access` is configured; without either, every path is open, which `obsei serve` allows only on a loopback address (`127.0.0.1`, `::1`, `localhost`). Binding to any other address requires `OBSEI_API_TOKEN` or `access`. Every token (`OBSEI_API_TOKEN` and each user’s `token_env`) must be at least 16 characters. Pipelines with `every_minutes` run on their schedule inside the server (see [Configuration](/configuration/#scheduling)). ## Sending feedback [Section titled “Sending feedback”](#sending-feedback) ```yaml sources: - key: tickets type: webhook config: secret_env: TICKETS_HOOK_SECRET items_path: tickets fields: {id: id, created_at: created_at, text: [subject, body], lang: locale} ``` Sign requests per [Standard Webhooks](https://www.standardwebhooks.com/), as the [webhook sink](/guides/sinks/#webhooks) and its libraries do: `webhook-id` (any unique id), `webhook-timestamp` (Unix seconds) and `webhook-signature: v1,`. Several space-separated signatures are accepted, so you can rotate secrets. A `whsec_` secret is base64-decoded; any other value is used as its raw bytes. Requests more than `max_age_seconds` (default 300) away from the server’s clock are refused, so a captured request cannot be replayed later. Senders that sign the body only, such as GitHub (`X-Hub-Signature-256: sha256=`), need `require_timestamp: false` on the source, which gives up that protection. Map `id` and `created_at` so that redeliveries are recognised as unchanged. ```bash id=msg_$(openssl rand -hex 12) ts=$(date +%s) # with a raw (non-whsec_) secret sig=$({ printf '%s.%s.' "$id" "$ts"; cat body.json; } | openssl dgst -sha256 -hmac "$TICKETS_HOOK_SECRET" -binary | base64) curl -X POST https://obsei.internal/ingest/support/tickets --data-binary @body.json \ -H "webhook-id: $id" -H "webhook-timestamp: $ts" -H "webhook-signature: v1,$sig" ``` Earlier release candidates expected `X-Obsei-Timestamp` and `X-Obsei-Signature-256`; senders now use the headers above. A request is accepted as a whole or not at all: an invalid item returns `400` naming the item. Items without text are skipped, and the reply counts them: `{"received": 3, "skipped": 1, "stored": 2}`. ## Access [Section titled “Access”](#access) `OBSEI_API_TOKEN` is an admin token. For teams, give each group its own token and role, or put obsei behind your SSO proxy (oauth2-proxy, Pomerium, Cloudflare Access) and map groups to roles: | Role | Can use | | ------- | ------------------------------------------------------------------------------------------------------------- | | viewer | Studio aggregates and themes (`/api/snapshot`) | | analyst | also redacted evidence, Ask and MCP (`/api/themes/*`, `/api/ask`, `/mcp`) | | admin | also pipeline run status, webhook deliveries and approvals (`/api/runs`, `/api/deliveries`, `/api/approvals`) | ```yaml access: users: - {name: support-leads, token_env: OBSEI_TOKEN_SUPPORT, role: viewer} - {name: voc-team, token_env: OBSEI_TOKEN_VOC, role: analyst} trusted_proxy: secret_env: OBSEI_PROXY_SECRET # the proxy sends it in X-Obsei-Proxy-Secret user_header: X-Forwarded-Email groups_header: X-Forwarded-Groups roles: {analyst: [voc], admin: [platform]} default_role: viewer ``` Requests without the proxy secret are refused, so the proxy cannot be bypassed. Every evidence, Ask, MCP and admin request is written to the audit log (`obsei audit`) with the user and path. Approvals over HTTP need a token or SSO proxy, even on a loopback address; without one, use `obsei approvals`. Give agents analyst tokens, never admin ones. ## Allowed hosts [Section titled “Allowed hosts”](#allowed-hosts) Against DNS rebinding, every route answers only to `localhost`, `127.0.0.1`, `[::1]`, the `--host` address and the names in `access.allowed_hosts`; other `Host` or `Origin` headers get 421. When bound to `0.0.0.0` without `allowed_hosts`, any host is accepted (a token or SSO proxy is required then), so list your public names there: ```yaml access: allowed_hosts: [voc.example.com, "*.corp.example"] ``` # Set up without editing YAML > Add sources, sinks and alerts with obsei add, check them with obsei check, browse every integration with obsei integrations, and get autocomplete for obsei.yaml in your editor. You never have to write `obsei.yaml` by hand. In an empty folder: ```bash obsei init # obsei.yaml, sample data and .obsei/secrets.env obsei add appstore # asks for the app id and countries, previews 3 redacted reviews obsei add slack # asks for the webhook URL (hidden), checks it, stores it as a secret obsei check # every source and sink: settings, secrets, egress, credentials obsei run # fetch, redact, label, deliver and store ``` ## Find an integration [Section titled “Find an integration”](#find-an-integration) `obsei integrations` lists every source, enricher and sink, built in or from an installed plugin, with its status (`verified` connectors have a live test), the secrets it needs and the provider’s page that explains how to get them: ```text $ obsei integrations zendesk zendesk source support beta Zendesk tickets secrets ZENDESK_EMAIL, ZENDESK_API_TOKEN keys https://support.zendesk.com/hc/en-us/articles/4408889192858-Managing-API-token-access-to-the-Zendesk-API ``` Filter by name or title (`obsei integrations review`), by `--category` (reviews, support, social, surveys, data, chat, issues, warehouse, notify, enrich), or print `--json` for scripts and agents. ## Add a source or sink [Section titled “Add a source or sink”](#add-a-source-or-sink) `obsei add TYPE` shows what the integration is and where to get its keys, then asks for each required setting, with a short description, and for the settings most people change. `--all` asks for every optional setting too. Then it: * stores each secret (entered hidden) in `.obsei/secrets.env`, owner-only and git-ignored, under an environment variable name such as `SLACK_WEBHOOK_URL`; `obsei.yaml` only gets the name (`webhook_url_env: SLACK_WEBHOOK_URL`). With `--key oncall` the name is `ONCALL_WEBHOOK_URL`, so two Slack sinks keep two secrets. If the variable is already set in your environment, leave the prompt blank to use it; * validates the settings with the integration’s own config model; * for a source, fetches and shows up to 3 records, redacted and labelled like `obsei try`; for a sink, builds it, checks the egress policy and, where it sends nothing (Slack, GitHub, Jira, Linear), checks the credentials; * if the egress policy blocks the sink’s host, asks to allow it and adds it to `egress:` in private mode; * adds the entry to the first pipeline (`-p NAME` for another), next to the existing ones, keeping your comments and layout, and loads the result before saving: a change that would not load is never written. A type that is both a source and a sink (`github_issues`, `webhook`, `sql`) takes `--as source` or `--as sink`. A type from an installed plugin, such as `teams`, also adds the plugin to `plugins:`. ### Without prompts [Section titled “Without prompts”](#without-prompts) `--yes` asks nothing, for CI, scripts and agents. Settings come from `--set FIELD=VALUE` (lists as `a, b` or `[a, b]`, nested fields as `fields.text=comment`), and secrets from the environment: ```bash export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... obsei add appstore --yes --set app_id=284882215 --set countries=us,gb,de obsei add slack --yes --set max_rating=2 obsei add csv --yes --key surveys --set path=nps.csv --set fields.text=comment ``` `--set webhook_url=VALUE` (the field name without `_env`) stores the value in `.obsei/secrets.env` instead; avoid it on shared machines, where command lines are visible to other users. Without the secret, `--yes` stops and names the variable to set. ### Alerts [Section titled “Alerts”](#alerts) `obsei add alert` adds an [alert](/guides/alerts/): it asks for the kind (`theme_spike`, `new_theme`, `digest`, `review_queue` or `pipeline_health`) and the sink that delivers it (`slack`, `notify`, `webhook` or `teams`), then that sink’s settings, as above. Without prompts: `obsei add alert --yes --set kind=digest --set to=slack`. Options such as `period_days` or `sensitivity` are edited in `obsei.yaml`. ## Check everything [Section titled “Check everything”](#check-everything) `obsei check` builds every source and sink of every pipeline (`-p NAME` for some) and every alert target, and reports each: ```text ✓ feedback/sample source csv: fetched a record ✓ feedback/appstore source appstore: fetched a record ✓ feedback/parquet sink parquet: built (nothing sent) ✗ feedback/slack sink slack: SLACK_WEBHOOK_URL is not set fix: set it in .obsei/secrets.env or the environment keys: https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/ 1 of 4 failed ``` Sources fetch one record, which is neither stored nor sent anywhere. Sinks are built (settings, secrets, egress) and, for Slack, GitHub, Jira and Linear, their credentials are checked with a request that delivers nothing; other sinks send nothing at all. `obsei check` exits with status 1 when anything failed, so it can gate a deploy. ## Editor autocomplete [Section titled “Editor autocomplete”](#editor-autocomplete) `obsei.yaml` files from `obsei init` and the [examples](/examples/) start with: ```yaml # yaml-language-server: $schema=https://docs.obsei.com/schema/obsei.schema.json ``` Editors that use the YAML language server (VS Code with the Red Hat YAML extension, Neovim, Helix, Zed) then complete keys, show each field’s description on hover, and flag typos and wrong values, including inside each source’s and sink’s `config:` by its `type`. Add the line to an existing `obsei.yaml` to get the same. The published schema covers the built-in integrations and the first-party plugins. For other installed plugins, write your own and point the first line at it: ```bash obsei schema config > obsei.schema.json # then: # yaml-language-server: $schema=./obsei.schema.json ``` ## Write your own integration [Section titled “Write your own integration”](#write-your-own-integration) `obsei plugin new NAME` creates a plugin package (a source with its config model, catalog entry, test and entry point) to start from; see [Plugins](/reference/plugins/#writing-a-plugin). # Sinks > Where obsei delivers feedback. Sinks receive only new or changed records, after redaction and enrichment, and are not called when a batch has none for them. Outbound sinks are checked against the egress policy when the pipeline starts; `sql` checks the database host in its URL (BigQuery and Snowflake accounts count as their cloud hosts, SQLite files as local). Add one with `obsei add TYPE`: it asks for the settings, keeps the secret in `.obsei/secrets.env`, checks the egress policy and, for Slack, GitHub, Jira and Linear, the credentials, without sending anything ([Set up without editing YAML](/guides/setup/)). | Type | What | Idempotency | Status | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -------- | | `webhook` | One CloudEvents JSON event per batch, signed per [Standard Webhooks](https://www.standardwebhooks.com/), retried; authors omitted by default | `webhook-id` per event, record ids in the payload | beta | | `slack` | Messages for matching feedback, capped per run | at-least-once | verified | | `notify` | One summary per batch to Telegram, Discord, email, Teams and 100+ services via [Apprise](https://github.com/caronc/apprise) (`obsei[notify]`) | at-least-once | beta | | `github_issues` | One issue per matching record | hidden marker, searched before creating | beta | | `jira` | One issue per matching record (Cloud with ADF, or Data Center) | `obsei-rec_...` label | beta | | `linear` | One issue per matching record | marker in the description | verified | | `parquet` | Parquet files for notebooks and lakes | file named by batch content | beta | | `sql` | Upsert into a table in any SQLAlchemy database or warehouse | delete and insert by id | beta | `verified` means a live test in [`packages/obsei/tests/live`](https://github.com/obsei/obsei/tree/master/packages/obsei/tests/live) exercises the connector end to end against the real service or data; `beta` connectors are covered by mocked tests only. ## Filters [Section titled “Filters”](#filters) Issue and chat sinks take `when` (labels from enrichers) and `max_rating`: ```yaml - type: jira config: base_url: https://acme.atlassian.net project_key: APP when: {classify.intent: [bug]} max_rating: 2 ``` `when` reads nested fields and yes/no values too, e.g. `{classify.fields.urgency: [today, right now]}` or `{filter.match: ["false"]}`. Instead of a list, a condition can test what a decision model stores: `{is: bug, min_confidence: 0.8}`, `{gte: today}` on a score field, `{min_probability: 0.7}` on a yes/no field, and `review: false` to skip records flagged for review. See [Routing](/guides/routing/#conditions) for every condition. ## Routes [Section titled “Routes”](#routes) `when` filters fan out: each sink checks every record. To send each record to one set of sinks, give sinks a `key` and add ordered `route:` rules to the pipeline; the first match wins, with a `default` and a `review` shortcut for records still flagged after the fallback: ```yaml sinks: - {key: jira, type: jira, config: {base_url: https://acme.atlassian.net, project_key: SUP, when: {}}} - {key: oncall, type: slack, config: {webhook_url_env: ONCALL_SLACK_WEBHOOK_URL}} - {key: lake, type: parquet, config: {directory: lake}} route: - name: urgent-bugs when: {classify.intent: [bug], classify.fields.urgency: {gte: today}} sinks: [jira, oncall] - default: [lake] ``` Sinks no route names still receive every record through their own `when`. The [Routing](/guides/routing/) guide covers the rules, the stored `route` enrichment and a full example. ## Webhooks [Section titled “Webhooks”](#webhooks) ```yaml - key: hub type: webhook config: url: https://feedback-hub.internal/obsei secret_env: FEEDBACK_HUB_SECRET headers: {X-Team: voc} # optional extra headers exclude: [author] # the default: pseudonyms are never forwarded ``` Each batch is one event, a [CloudEvents 1.0](https://cloudevents.io/) JSON envelope; `source` is `obsei//`: ```json {"specversion": "1.0", "id": "evt_4f0c…", "type": "obsei.records.routed", "source": "obsei/support/hub", "time": "2026-10-15T09:00:00.123456+00:00", "datacontenttype": "application/json", "data": {"records": [{"id": "rec_…", "text": "…"}]}} ``` The headers follow [Standard Webhooks](https://www.standardwebhooks.com/): | Header | Value | | ------------------- | ----------------------------------------------------------------------------------------- | | `webhook-id` | the event `id`; the same on every retry of the event, so use it to skip repeats | | `webhook-timestamp` | Unix seconds of the attempt | | `webhook-signature` | with `secret_env`: `v1,` | Use a `whsec_` secret, `whsec_` followed by 24 to 64 random bytes in base64 (`echo "whsec_$(openssl rand -base64 32)"`), and the standardwebhooks libraries take it as is. Any other value is used as its raw UTF-8 bytes; pass it to the libraries as bytes (Python `Webhook(secret.encode())`, JavaScript `new Webhook(secret, {format: "raw"})`). ```python from standardwebhooks import Webhook # pip install standardwebhooks event = Webhook(os.environ["FEEDBACK_HUB_SECRET"]).verify(body, headers) # raw body bytes for record in event["data"]["records"]: ... ``` ```js import { Webhook } from "standardwebhooks"; // npm install standardwebhooks const event = new Webhook(process.env.FEEDBACK_HUB_SECRET).verify(rawBody, req.headers); ``` Both refuse events more than 5 minutes old. Automation tools take these events too: point an n8n Webhook node or a Zapier “Catch Hook” at the URL and read `data.records`. Connection errors, `429` and `5xx` replies are retried up to 3 attempts in all, waiting 1 s then 2 s (or the reply’s `Retry-After`, up to 30 s). Other `4xx` replies fail at once. If the last attempt fails, the run fails: the batch is not stored and the next run sends it again as a new event with a new `webhook-id`, so receivers should also deduplicate by record `id`. Each event’s final outcome (event id, pipeline, sink, host, type, attempts, status, error) is kept in the store’s `deliveries` table; admins read the latest 100 at `GET /api/deliveries` on [`obsei serve`](/guides/serve/). Earlier release candidates posted `{"records": [...]}` signed with `X-Obsei-Timestamp` and `X-Obsei-Signature-256`. Receivers now read `data.records` and verify the headers above. ## Notifications [Section titled “Notifications”](#notifications) `notify` sends one short message per batch through [Apprise](https://github.com/caronc/apprise) (`pip install 'obsei[notify]'`): how many records matched, then up to `max_items` (default 5) redacted first lines with links. It takes `when` and `max_rating` like the issue sinks. The Apprise URLs hold tokens, so they are read from the environment variable `urls_env` names (default `APPRISE_URLS`); separate several with spaces or commas. ```yaml - type: notify config: urls_env: VOC_ALERT_URLS when: {classify.intent: [bug, churn_risk]} max_rating: 2 ``` | Service | `VOC_ALERT_URLS` | | ----------------------------------------- | ----------------------------------------------------------- | | Telegram | `tgram://{bot_token}/{chat_id}` | | Discord | `discord://{webhook_id}/{webhook_token}` | | Email | `mailtos://{user}:{app_password}@gmail.com?to=voc@acme.com` | | Microsoft Teams (Power Automate workflow) | `workflows://{host}:{port}/{workflow}/{signature}` | The [Apprise wiki](https://github.com/caronc/apprise/wiki) lists every service. Each target must pass the egress policy: obsei checks the service’s API host (`api.telegram.org`, `discord.com`), the SMTP server of email, and the host in the URL for self-hosted services (`json://`, `ntfys://` on your server, Matrix). A service whose host obsei cannot tell, such as `ntfy://` on ntfy.sh, needs hybrid mode. Apprise makes its own connections, so they do not go through obsei’s HTTP client. # Sources > Built-in sources and their configuration. Add any of these with `obsei add TYPE`, which asks for the settings and keys and previews a few records; `obsei integrations` lists each one’s secrets and where to get them ([Set up without editing YAML](/guides/setup/)). | Type | What | Auth | Status | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------- | -------- | | `csv` | Survey exports, ticket dumps, NPS verbatims | none | verified | | `jsonl` | Ticket dumps and other JSON Lines exports | none | beta | | `rest` | Any JSON API, with page, offset, link, next-URL or cursor pagination | bearer or headers from env | beta | | `webhook` | Pushed feedback via `obsei serve` (`POST /ingest/{pipeline}/{key}`) | Standard Webhooks signature | beta | | `appstore` | Apple public review feed, any list of countries | none | verified | | `appstoreconnect` | Your apps’ reviews in every territory (official API) | `.p8` key, `obsei[apple]` | beta | | `playstore` | Your apps’ reviews (official Android Publisher API) | service account, `obsei[google]` | beta | | `github_issues` | Issues and comments, GitHub or GitHub Enterprise | `GITHUB_TOKEN` (optional) | verified | | `hackernews` | Stories and comments matching a query | none | verified | | `bluesky` | Posts matching a query, optionally by language | none or app password | beta | | `youtube` | Comments on videos or a whole channel | `YOUTUBE_API_KEY` | beta | | `rss` | RSS 2.0 and Atom: forums, status pages, review sites | none | verified | | `reddit` (community plugin, [not on PyPI](#reddit-plugin)) | Subreddit posts, comments and searches via RSS | none | beta | | `sql` | Any SQLAlchemy database or warehouse, incremental by a cursor column | URL in env, `obsei[sql]` | beta | | `filedrop` | New or changed CSV, JSON Lines and JSON files in a folder (SFTP drop, mounted bucket) | none | beta | | `imap` | Feedback mailboxes | username and password in env | beta | | `mcp` | A tool on any MCP server (stdio command or HTTP URL) | none | beta | | `zendesk` | Tickets, incremental export | email and API token | beta | | `freshdesk` | Tickets updated since the last run | API key | beta | | `intercom` | Conversations, in the US, EU or AU data region | access token | beta | | `gong` | Call transcripts, customer speech only | access key and secret | beta | `verified` means a live test in [`packages/obsei/tests/live`](https://github.com/obsei/obsei/tree/master/packages/obsei/tests/live) exercises the connector end to end against the real service or data; `beta` connectors are covered by mocked tests only. ## Field mapping [Section titled “Field mapping”](#field-mapping) `csv`, `jsonl`, `filedrop`, `sql`, `rest`, `webhook` and `mcp` map fields with dotted paths: ```yaml fields: text: [subject, body] # several fields are joined id: ticket.id created_at: ticket.created # ISO 8601, RFC 2822 or epoch seconds rating: score author: requester.email # pseudonymised, never stored in clear lang: locale url: link context: [plan, region] # kept as string metadata ``` ## Recipes with the REST source [Section titled “Recipes with the REST source”](#recipes-with-the-rest-source) `rest` reads any JSON API. Set `items_path` to the list of items, map `fields`, and pick the pagination the API uses: `page` or `offset` (with `page_param`, `page_size_param`), `link` (the `Link: rel="next"` header), `next_url` or `cursor` (both read `next_path`; `cursor` sends the token as `cursor_param`). Credentials come from `bearer_token_env` or `secret_headers` and are only sent to the configured host, never to hosts named by pagination. APIs that return HTML (Mastodon, forums, some helpdesks) can set `text_format: html`: the mapped `text` fields are converted to plain text (tags removed, entities decoded, paragraphs kept as line breaks) before redaction. The default is `plain`, which keeps the text as returned. ```yaml - key: mastodon type: rest config: url: https://mastodon.social/api/v1/timelines/tag/yourproduct params: {limit: 40} pagination: link max_pages: 5 text_format: html fields: {text: content, id: id, created_at: created_at, author: account.acct, lang: language, url: url} ``` Complete recipes: | API | Example | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Trustpilot business reviews (API key header, page paging) | [`trustpilot-rest.yaml`](https://github.com/obsei/obsei/blob/master/examples/trustpilot-rest.yaml) | | HubSpot tickets (cursor on `paging.next.after`) and ServiceNow incidents (`sysparm_offset`) | [`crm-and-itsm-rest.yaml`](https://github.com/obsei/obsei/blob/master/examples/crm-and-itsm-rest.yaml) | | Mastodon hashtag timeline (`Link` header, HTML) | [`social-listening.yaml`](https://github.com/obsei/obsei/blob/master/examples/social-listening.yaml) | | Atlassian Marketplace app reviews (public API, `next_url` on `_links.next.href`) | [`atlassian-marketplace-reviews.yaml`](https://github.com/obsei/obsei/blob/master/examples/atlassian-marketplace-reviews.yaml) | | Google Business Profile reviews of your locations (OAuth bearer token, `cursor` on `nextPageToken`; beta) | [`google-business-reviews.yaml`](https://github.com/obsei/obsei/blob/master/examples/google-business-reviews.yaml) | | Confluence Cloud pages of a space (basic auth header, `Link` header, HTML) | [`confluence-pages.yaml`](https://github.com/obsei/obsei/blob/master/examples/confluence-pages.yaml) | | WordPress.org plugin reviews (the `rss` source on the plugin’s review feed) | [`wordpress-plugin-reviews.yaml`](https://github.com/obsei/obsei/blob/master/examples/wordpress-plugin-reviews.yaml) | More complete setups are on the [Examples](/examples/) page. ## Reddit plugin [Section titled “Reddit plugin”](#reddit-plugin) `reddit` is a first-party plugin, not on PyPI yet. Install it from Git, then allow it in `obsei.yaml` with `plugins: [reddit]`: ```bash pip install "obsei-reddit @ git+https://github.com/obsei/obsei#subdirectory=plugins/obsei-reddit" # or, with uv tool: uv tool install obsei \ --with "obsei-reddit @ git+https://github.com/obsei/obsei#subdirectory=plugins/obsei-reddit" ``` ## App Store feeds [Section titled “App Store feeds”](#app-store-feeds) `appstore` reads Apple’s public review feed, which needs no key but is unofficial. Since mid-2026 Apple’s CDN often serves one spelling of the feed empty while another has the reviews, so obsei tries four spellings, as XML and then JSON, before it treats a country as empty. Apple now returns only the latest 50 reviews per country (later pages are empty), so run it often enough that new reviews don’t scroll past. For your own apps, `appstoreconnect` uses the official API and covers every review. ## Global coverage [Section titled “Global coverage”](#global-coverage) Use the country and language filters to follow every market you serve: `appstore.countries` takes ISO 3166 alpha-2 codes (`us`, `br`, `jp`, `in`, `ng`, …), `appstoreconnect.territories` takes alpha-3 codes (`USA`, `BRA`, `JPN`, `IND`, `NGA`), and `bluesky.lang` filters by language. Records keep their original language; enrichers never translate the stored text. # Studio > The read-only web UI with a knowledge-graph explorer. [Open the demo](/demo/): eight weeks of synthetic feedback in eleven languages from app stores, a help desk, an NPS survey, Bluesky and GitHub, with a login spike after an app update. No real people or customers are in it; the contact details and ID numbers in a few records are made up (checksum-valid, so redaction catches them). Studio shows k-anonymous aggregates, stable themes with seven-day trends and a weekly sparkline (themes whose last seven days at least doubled the seven before are marked **Rising**), a knowledge graph that links themes to sources, languages, intents and sentiment, and redacted evidence for each theme, with placeholders such as `` highlighted. It never shows authors and never edits data. ## Privacy panel [Section titled “Privacy panel”](#privacy-panel) Every Studio (live, export and demo) has a **Privacy** panel, built from aggregates only, so the viewer role sees it too: * how often each redaction placeholder (``, ``, ``, ``, ``, ``, …) occurs in stored text, and in how many records; * the k-anonymity threshold, and how many themes and source, language or label groups it hides; * how many distinct pseudonymised authors there are (never who they are); * the egress mode (`air_gapped`, `private` or `hybrid`) of the server, in live mode only. ## Decisions [Section titled “Decisions”](#decisions) When `classify` stores fields (for example from a [decision model](/guides/models/#decision-models) with `team`, `urgency` or yes/no fields), Studio shows a **Decisions** panel: k-anonymous counts per answer (score levels in order) and the share of records marked for review because an answer was below its confidence cutoff. Evidence lists each record’s labels with the model’s confidence. Pipelines with [routes](/guides/routing/) also get a **Routes** chart: records per route rule. ## How the demo is built [Section titled “How the demo is built”](#how-the-demo-is-built) `obsei demo` builds the static demo from the synthetic records in `obsei/demo.py`: it redacts them as ingest would, labels them, groups themes, and exports a snapshot marked `demo`. Only the demo shows a short guided intro, before-and-after redaction cards (the raw text exists only because the data is synthetic; Studio never has raw text otherwise) and a few **Ask your data** answers recorded from the dataset, citing record ids shown in its evidence. Demo records are routed with the routes of `examples/decision-routing.yaml` without its refunds rule (billing chosen by the demo’s `team` field), using the same routing code as a pipeline. In your deployment Ask uses your own model, and agents get the same answers over MCP. The demo’s labels (sentiment, intent, team, urgency and an angry yes/no) come from Julia-1, a 144M-parameter decision model, run once on a CPU and committed as `obsei/demo_labels.json`, so the build needs no model server. They are shown as the model produced them, mistakes included: 49% of records are marked for review (see [known limits](/guides/models/#known-limits)). To label it again with a decision model, put its endpoint URL in an environment variable: ```bash export OBSEI_DEMO_DECISION_URL=... # the decision endpoint URL your server documents uv run obsei demo --decision-url-env OBSEI_DEMO_DECISION_URL \ --save-labels packages/obsei/src/obsei/demo_labels.json --out docs/public/demo ``` The demo groups the same issue across languages with the multilingual model (needs network to Hugging Face once): ```bash uv run --extra embeddings obsei models download uv run --extra embeddings obsei demo --embedder local --out docs/public/demo ``` `obsei demo` follows `OBSEI_EGRESS_MODE` (air-gapped by default), so it loads the model only from the local cache (`OBSEI_MODELS_DIR`, or the fastembed cache) that `obsei models download` fills. `--embedder` takes the same names as `themes.embedder`: `hashing`, `auto`, `local` or `local:`; with the offline `hashing` embedder each language forms its own theme. The embedder that built the demo is recorded as `embedder` in `data.json`. Demo themes carry curated labels; real themes are labelled from shared keywords or by your `labeler` model. ## Live [Section titled “Live”](#live) `obsei serve` hosts Studio at `/studio/`. Studio asks for `OBSEI_API_TOKEN` and keeps it for the browser tab only. The **Ask** box uses `obsei ask` on the server. ## Static export [Section titled “Static export”](#static-export) ```bash obsei studio --out site/ # index.html, app.js, styles.css and data.json ``` Host the folder anywhere (an internal bucket, Cloudflare Pages behind Access). The export holds only k-anonymous aggregates and a few redacted quotes per theme. ## Slack [Section titled “Slack”](#slack) Create a Slack app with a slash command `/obsei` pointing to `https://your-host/slack/commands`, set `SLACK_SIGNING_SECRET`, and allow `hooks.slack.com` in the egress policy. Answers are posted to the channel with record-id citations. # Themes, dedupe and ask > Stable themes, near-duplicates and cited answers. ```bash obsei themes # embed new feedback, update themes, list them obsei ask "Why are users in Brazil asking for refunds?" ``` ## Stable themes [Section titled “Stable themes”](#stable-themes) Each new record is embedded, marked as a near-duplicate of an earlier record when it is almost identical, and assigned to the nearest theme or starts a new one. Theme ids never change, so dashboards, tickets and agents can refer to them over time. ```yaml themes: embedder: auto # local when obsei[embeddings] is installed, else hashing; or an llms name similarity: null # default 0.3 hashing, 0.65 local multilingual, 0.75 other models duplicate_similarity: 0.9 labeler: ollama # llms name; without it, keyword labels label_language: English # labels in your team's language; feedback stays as written k_anonymity: 5 ``` The built-in `hashing` embedder needs no model and works in every script, but it groups by wording, so the same issue in two languages forms two themes. To group by meaning across 50+ languages, use the local multilingual model (CPU only, about 220 MB, no PyTorch): ```bash uv tool install --force "obsei[mcp,embeddings]" # or: pip install "obsei[embeddings]" obsei models download --dir /models # once, with network export OBSEI_MODELS_DIR=/models # air-gapped runs load it from here ``` With the extra installed, the default `embedder: auto` uses this model; set `embedder: local` to fail instead of falling back to `hashing` when it is missing: ```yaml themes: embedder: local # or local:intfloat/multilingual-e5-large for higher quality ``` Thresholds differ between models; tune `similarity` if themes split or merge too much. A model served by Ollama or vLLM (for example `bge-m3`) also works: ```yaml llms: ollama: base_url: http://localhost:11434/v1 model: qwen3:8b embedding_model: bge-m3 themes: embedder: ollama # the llms name, not "local" ``` `auto`, `hashing`, `local` and `local:` always mean the built-in embedders, so do not use them as `llms` names for an embedding endpoint. ## k-anonymity [Section titled “k-anonymity”](#k-anonymity) Themes and aggregate groups smaller than `k_anonymity` records are never labelled, listed, exported or shown in Studio, so small groups cannot single out a customer. ## Ask [Section titled “Ask”](#ask) `obsei ask` retrieves the most relevant feedback, adds the theme overview, and asks your model to answer in the language of the question, quoting customers in their own language and citing record ids. Citations are checked against the records actually retrieved. # Privacy controls > What obsei does to protect personal data. obsei provides controls that *support* compliance with privacy laws. It does not make compliance claims: the organisation deploying it remains the controller and decides its lawful basis, retention and transfers. ## At ingest [Section titled “At ingest”](#at-ingest) * **Redaction.** Detectors run before storage, models and sinks, over the text, context fields, source URL and native id, and author locale. Identifiers with checksums are validated to avoid false positives. Digits in any script are recognised (Arabic-Indic, Devanagari, full-width and others). | Region | Detectors | | -------------- | ------------------------------------------------------------------------------ | | Global | email, payment card (Luhn), IBAN (mod 97), IPv4, IPv6, phone numbers | | North America | US SSN, Canadian SIN | | United Kingdom | National Insurance number, NHS number | | European Union | France NIR, Spain DNI/NIE, Italy codice fiscale, Poland PESEL, Netherlands BSN | | Latin America | Brazil CPF, Mexico CURP | | Asia-Pacific | China resident ID, Singapore NRIC/FIN, Japan My Number, Australia TFN | | India | Aadhaar (Verhoeff), PAN | | Africa | South Africa ID | In `obsei.yaml`, `privacy.regions` takes the keys `global`, `north_america`, `uk`, `eu`, `latam`, `apac`, `india` and `africa` (all by default). * **Names (optional).** Regexes cannot find person names. The `names` extra ([how to add an extra](/quickstart/#optional-extras)) adds a local multilingual GLiNER model that replaces names with `` after the regex pass: ```yaml privacy: names: enabled: true model: /models/gliner_multi_pii-v1 # downloaded once; required in air-gapped mode threshold: 0.5 ``` Download the model with `huggingface-cli download urchade/gliner_multi_pii-v1 --local-dir /models/gliner_multi_pii-v1`. It runs on CPU; expect tens of milliseconds per record. Like any model it can miss names, so treat it as risk reduction, not a guarantee. * **Pseudonyms.** Author handles become salted HMAC pseudonyms (`psn_...`). Without `OBSEI_PSEUDONYM_SALT`, authors are dropped. Pseudonymised data is still personal data. * **Purpose.** Every record carries a purpose tag (default `feedback-analytics`). ## At rest and in use [Section titled “At rest and in use”](#at-rest-and-in-use) * The DuckDB file is encrypted (AES via OpenSSL) with `OBSEI_DB_KEY`. Writing it needs DuckDB’s `httpfs` extension. In `private` or `hybrid` egress mode the first encrypted write downloads it if it is missing; air-gapped mode (the default) never downloads it. To pre-install it, run `INSTALL httpfs` once with network access into a directory, copy that directory over (same DuckDB version and platform) and point `OBSEI_DUCKDB_EXTENSIONS` at it. The container image ships with it pre-installed. `obsei doctor` reports whether it is ready. On a machine with network access, the one-time install is `"$(uv tool dir)/obsei/bin/python" -c "import duckdb; duckdb.connect().execute('INSTALL httpfs')"` (or the same `python -c` in the environment where you installed obsei with pip). * Egress is air-gapped by default; public model and sink endpoints must be allowed explicitly. Every redirect hop of a sink request is checked as well, and a redirect to another origin drops credentials and custom headers. The REST source sends its credentials only to the origin of its configured `url`, never to hosts named by pagination links. * Themes, their source, language and intent breakdowns, average ratings and graph links are shown only when they come from at least `themes.k_anonymity` people (distinct author pseudonyms; records without an author count individually). Theme labels never quote feedback. * MCP tools never return author pseudonyms. Webhook and Parquet sinks omit them by default. ## Data subject requests and retention [Section titled “Data subject requests and retention”](#data-subject-requests-and-retention) ```bash obsei export --author "ana@example.com" --out ana.jsonl # access / portability obsei forget --author "ana@example.com" # erasure obsei forget --older-than-days 365 # retention obsei forget --source appstore --instance 284882215 # remove a source obsei audit # log of erasures and exports ``` These commands use the store from `obsei.yaml` (`-c` picks another config, `--db` overrides the path) and never create a new database. Every `forget` and `export` is written to an append-only audit log inside the encrypted store, with the filters and counts but never the raw handle. `forget` leaves tombstones: the erased record ids and, for `--author`, the author pseudonym. A source that sends the same records again (CSV, file drop, REST without `since_param`) cannot bring them back, and new records by an erased author are not stored. Tombstones hold no raw personal data. `forget` erases records from the obsei database only. Copies already delivered to sinks (Parquet files, SQL tables, Slack messages, Jira, Linear or GitHub issues, webhook receivers) are not touched; erase them in those systems as part of the same request. These map to rights found in the GDPR and UK GDPR, India’s DPDP Act, Brazil’s LGPD, California’s CCPA/CPRA, Japan’s APPI, South Africa’s POPIA, China’s PIPL, Singapore’s PDPA and others. Check the requirements that apply to you. # Privacy policy > What data obsei, its plugins and its websites collect. **The obsei software collects nothing.** The `obsei` package, its MCP server and the plugins and extensions for AI tools (Claude, Codex, Copilot, VS Code, Cursor, Gemini and others) send no telemetry, usage data or crash reports, and have no account or sign-up. They run on your infrastructure and store data only where your `obsei.yaml` says. The maintainers never receive your data. Network requests go only to the sources, models and sinks you configure, under the egress policy you set (see [Privacy controls](/privacy/overview/)). Installing obsei downloads it from PyPI, or the image from GitHub Container Registry; those services apply their own privacy policies. **AI tools.** When an AI tool calls the obsei MCP server, the tool results (redacted feedback text, labels and record ids, never author pseudonyms) go to that tool and the model provider it uses, under their terms. You choose the tool and its provider. **Websites.** obsei.com, docs.obsei.com and blog.obsei.com are static sites hosted on Cloudflare Pages. They set no cookies and use no advertising or cross-site tracking. They use [Cloudflare Web Analytics](https://www.cloudflare.com/web-analytics/) to count page views: its script stores nothing in your browser, does not fingerprint you or follow you across sites, and reports only aggregate figures such as the page, referrer, country, browser and load time. Cloudflare also processes request data such as IP addresses to deliver and protect the sites, under the [Cloudflare privacy policy](https://www.cloudflare.com/privacypolicy/). **Contact.** Questions go to [GitHub Discussions](https://github.com/obsei/obsei/discussions); security issues are handled as described in [SECURITY.md](https://github.com/obsei/obsei/blob/master/SECURITY.md). # CLI > obsei command reference. | Command | Purpose | | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `obsei init [DIR] [--force] [--offline]` | Create `obsei.yaml`, a sample dataset and `.obsei/secrets.env` (a random `OBSEI_DB_KEY` and `OBSEI_PSEUDONYM_SALT`, owner-only, added to `.gitignore`, never overwritten), and install DuckDB’s `httpfs` extension for encryption and, with `obsei[local]`, the classifier model, enabling the `classify` enricher (`--offline` skips the downloads; `--force` replaces only `obsei.yaml` and `feedback.csv`) | | `obsei integrations [QUERY] [--category C] [--json]` | List every source, enricher and sink, built in or from an installed plugin, with its status, the secrets it needs and where to get them ([Setup](/guides/setup/#find-an-integration)) | | `obsei add TYPE\|alert [-c FILE] [-p NAME] [--key KEY] [--as source\|sink] [--set FIELD=VALUE ...] [--all] [--yes]` | Add a source, sink or alert to `obsei.yaml`: asks for its settings, stores secrets in `.obsei/secrets.env`, previews a source or checks a sink, allows the sink’s host in `egress:` with consent, and keeps comments; `--yes` asks nothing ([Setup](/guides/setup/#add-a-source-or-sink)) | | `obsei check [-c FILE] [-p NAME]` | Build every source, sink and alert target, fetch one record per source and check sink credentials where that sends nothing; exits 1 if any failed | | `obsei try [LINK\|FILE] [-c FILE] [-p NAME] [--limit N] [--no-enrich] [--json]` | Preview up to N (default 10) records per source as a table of redacted text and labels, with a redaction summary; from the configured sources, or an App Store, GitHub, Hacker News or RSS/Atom link or a `.csv`/`.jsonl`/`.json` file (see [Quickstart](/quickstart/#preview-with-obsei-try)). Nothing stored or sent; `--json` prints JSON Lines | | `obsei run [-c FILE] [-p NAME] [--db PATH] [--every MINUTES]` | Run pipelines, then check alerts | | `obsei themes [-c FILE] [--db PATH]` | Embed new feedback, update stable themes, then check alerts | | `obsei alerts check [-c FILE] [--db PATH] [--dry-run]` | Check every [alert](/guides/alerts/) now and deliver those due (`--dry-run`: print only, send and record nothing) | | `obsei alerts list [-c FILE] [--db PATH]` | Show when each alert key last fired, and snoozes and acks | | `obsei alerts snooze ALERT KEY [--hours N] [-c FILE] [--db PATH]` | Silence one alert key for N hours (default 24) | | `obsei alerts ack ALERT KEY [--by NAME] [-c FILE] [--db PATH]` | Acknowledge one alert key: silent for 7 days | | `obsei ask QUESTION [-c FILE] [--db PATH]` | Answer from your feedback with citations; with `ask_judge`, also a grounding score | | `obsei eval GOLD.csv \| --demo \| --from-store [-c FILE] [-p NAME] [--db PATH] [--json] [--target-precision P] [--min-accuracy A]` | Measure the `classify` enricher on labelled feedback and suggest a cutoff per question; exits 1 only when `--min-accuracy` is not met ([Models](/guides/models/#measure-on-your-own-data)) | | `obsei correct FILE.csv [-c FILE] [-p NAME] [--db PATH]` | Store human labels (`id` and a column per question) in place of the model’s answers | | `obsei reprocess [-c FILE] [-p NAME] [--db PATH] [--since DATE] [--dry-run]` | Relabel stored records with the current `classify` config, keeping human labels, and report what changed | | `obsei studio --out DIR [-c FILE] [--db PATH]` | Export a static Studio snapshot | | `obsei demo [--out DIR] [--embedder hashing\|local\|local:MODEL] [--decision-url-env VAR [--decision-model NAME] [--save-labels FILE]]` | Build the synthetic multilingual demo (default: offline `hashing` embedder and the committed decision-model labels) | | `obsei models download [--embeddings MODEL] [--classifier MODEL ...] [--dir DIR]` | Fetch the local embedding model (`obsei[embeddings]`) and classifier models (`obsei[local]`) for offline use; by default, those of the installed extras | | `obsei mcp [-c FILE] [--db PATH]` | MCP server over stdio (read-only, except to queue a proposal) | | `obsei approvals list [--status pending\|approved\|rejected\|failed] [-c FILE] [--db PATH] [--unencrypted]` | List what agents proposed, newest first | | `obsei approvals show ID [-c FILE] [--db PATH] [--unencrypted]` | Show a proposal and the exact issue text approving it sends | | `obsei approvals approve ID [--by NAME] [-c FILE] [--db PATH]` | Open the proposed issue with the configured sink, once (a failed one can be approved again) | | `obsei approvals reject ID [--by NAME] [--reason TEXT] [-c FILE] [--db PATH] [--unencrypted]` | Reject a proposal; nothing is sent | | `obsei serve [-c FILE] [--db PATH] [--host] [--port]` | Scheduled pipelines, webhook intake, MCP over HTTP, Studio (see [Server](/guides/serve/)) | | `obsei export --author HANDLE [--out FILE] [-c FILE] [--db PATH] [--unencrypted]` | Export one author’s records | | `obsei forget --author / --source [--instance] / --older-than-days [-c FILE] [--db PATH] [--unencrypted]` | Erase records (tombstoned so they are never stored again) | | `obsei audit [--limit N] [-c FILE] [--db PATH] [--unencrypted]` | Show the erasure, export, access and approval log | | `obsei doctor [-c FILE]` | Check environment, where the secrets came from, encryption, egress mode and plugins | | `obsei schema [record\|config]` | Print the Feedback Record JSON Schema, or `obsei.yaml`’s with the config of every installed integration ([editor autocomplete](/guides/setup/#editor-autocomplete)) | | `obsei plugin new NAME [--dir DIR]` | Create a plugin package from the template ([Plugins](/reference/plugins/#writing-a-plugin)) | | `obsei version` (or `--version`) | Print the version | `-c/--config` defaults to `obsei.yaml` (`OBSEI_CONFIG`). `--db` overrides the database path (`OBSEI_DB`); otherwise commands that read the config use `store.path`. `--unencrypted` opens a database without `OBSEI_DB_KEY` (encrypted disks only); commands that read the config use `store.unencrypted: true` instead. Every command loads `.obsei/secrets.env` next to the config (`KEY=value` lines) and sets the variables the environment does not already set. A wrong `OBSEI_DB_KEY` fails with “wrong encryption key”; a salt shorter than 16 bytes fails before anything is fetched. ## Environment [Section titled “Environment”](#environment) | Variable | Purpose | | ----------------------------------------- | ----------------------------------------------------------------------------------------------- | | `OBSEI_CONFIG` | Path to `obsei.yaml` | | `OBSEI_DB` | Database path override | | `OBSEI_DB_KEY` | Database encryption key (16+ characters) | | `OBSEI_DUCKDB_EXTENSIONS` | Directory with pre-installed DuckDB extensions (`httpfs`) | | `OBSEI_PSEUDONYM_SALT` | Author pseudonym salt (16+ characters) | | `OBSEI_EGRESS_MODE`, `OBSEI_EGRESS_ALLOW` | Egress policy | | `OBSEI_MODELS_DIR` | Directory for local models (`obsei models download --dir`); air-gapped runs load them from here | | `OBSEI_API_TOKEN` | Admin bearer token for `obsei serve` | | `SLACK_SIGNING_SECRET` | Enables the `/obsei` Slack command | # Plugins > First-party plugins, and how to write a source, enricher or sink. Plugins are separate Python packages. Installing one never runs its code: obsei loads an installed plugin only when `obsei.yaml` allows it by name. ```yaml plugins: [typeform, teams] ``` ## First-party plugins [Section titled “First-party plugins”](#first-party-plugins) These live in the repository’s [`plugins/`](https://github.com/obsei/obsei/tree/master/plugins) directory and are tested with obsei in CI. | Plugin | Type | Kind | What it does | | ------------------------------------------------------------------------------------- | ---------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`obsei-reddit`](https://github.com/obsei/obsei/tree/master/plugins/obsei-reddit) | `reddit` | source | Subreddit posts, comments and searches from Reddit’s public RSS feeds, no API key. | | [`obsei-typeform`](https://github.com/obsei/obsei/tree/master/plugins/obsei-typeform) | `typeform` | source | Completed Typeform survey responses through the Responses API with your token, incremental; picks text, rating, language and an optional pseudonymised respondent id from chosen fields. | | [`obsei-teams`](https://github.com/obsei/obsei/tree/master/plugins/obsei-teams) | `teams` | sink | Posts matching, redacted feedback to a Microsoft Teams channel as an Adaptive Card through a Power Automate Workflows webhook. | `obsei-teams` is on PyPI; `obsei-reddit` and `obsei-typeform` install from Git: ```bash pip install obsei-teams pip install "obsei-reddit @ git+https://github.com/obsei/obsei#subdirectory=plugins/obsei-reddit" pip install "obsei-typeform @ git+https://github.com/obsei/obsei#subdirectory=plugins/obsei-typeform" # uv tool: repeat --with for each plugin, and keep your extras uv tool install --force obsei --with obsei-teams ``` Each plugin’s README lists its configuration. The `teams` sink posts to public Microsoft hosts, so private egress mode needs them allowed: `OBSEI_EGRESS_ALLOW=logic.azure.com,environment.api.powerplatform.com`. Complete configurations are in [`examples/surveys-typeform.yaml`](https://github.com/obsei/obsei/blob/master/examples/surveys-typeform.yaml) and [`examples/alerts-teams.yaml`](https://github.com/obsei/obsei/blob/master/examples/alerts-teams.yaml). ## Writing a plugin [Section titled “Writing a plugin”](#writing-a-plugin) Start from the template: ```bash obsei plugin new acme # creates ./obsei-acme: package obsei_acme, type acme, a test pip install -e obsei-acme # then allow it in obsei.yaml: plugins: [acme] obsei add acme # asks for its settings and previews a record ``` In this repository, the template is [`plugins/template`](https://github.com/obsei/obsei/tree/master/plugins/template); `obsei plugin new acme --dir plugins/obsei-acme` creates a first-party plugin from it. A plugin exposes a `register(registry)` hook through the `obsei.plugins` entry point: ```toml [project] name = "obsei-example" dependencies = ["obsei>=1.0,<2"] [project.entry-points."obsei.plugins"] example = "obsei_example:register" ``` The hook pairs each component with a pydantic config model, which validates the `config` block in `obsei.yaml`: ```python from collections.abc import Iterator from datetime import UTC, datetime from typing import ClassVar from pydantic import BaseModel, ConfigDict, Field from obsei.core.context import Context from obsei.core.plugin import Catalog, factory from obsei.core.protocols import Cursor from obsei.core.record import Record, SourceRef from obsei.core.registry import Registry class ExampleConfig(BaseModel): model_config = ConfigDict(extra="forbid") greeting: str = Field(default="hello", description="Text of the one record this source yields.") class ExampleSource: name: ClassVar[str] = "example" def __init__(self, config: ExampleConfig, ctx: Context) -> None: self.config = config self.ctx = ctx def fetch(self, cursor: Cursor | None) -> Iterator[tuple[Record, Cursor]]: record = Record( source=SourceRef(type=self.name, native_id="1"), text=self.config.greeting, created_at=datetime.now(UTC), ) yield record, {"done": True} def register(registry: Registry) -> None: registry.add_source( "example", factory( ExampleConfig, ExampleSource, Catalog(title="Example", category="data", docs_url="https://github.com/you/obsei-example"), ), ) ``` The `Catalog` is what `obsei integrations` and `obsei add` show: a `title`, a `category` (reviews, support, social, surveys, data, chat, issues, warehouse or notify), a `status` (`beta` until a live test covers it), `docs_url`, and `help_url`, the provider’s page on getting the credential. Give every config field a `description`: `obsei add` shows it at each prompt and editors show it on hover. Mark each `*_env` field that names a secret with `json_schema_extra=SECRET` (from `obsei.core.plugin`), so `obsei add` asks for the value hidden and stores it in `.obsei/secrets.env`: ```python token_env: str = Field( default="ACME_TOKEN", description="Environment variable with an Acme API token.", json_schema_extra=SECRET, ) ``` Sinks can also implement `verify()`, which checks credentials and the target without delivering anything; `obsei add` and `obsei check` call it. The protocols live in `obsei.core.protocols`: * **Sources** implement `fetch(cursor)` and yield `(record, cursor)` pairs. obsei stores the cursor of the last record of each batch once every sink accepted it and passes it back on the next run, so make each yielded cursor a safe place to resume from. * **Enrichers** implement `enrich(batch)` and return one `Enrichment` or `None` per record, with `name` and `version` class attributes. * **Sinks** implement `send(batch)` and return a `SinkResult`. They must be idempotent by `Record.id`. Use the `Context` passed to the constructor rather than your own clients: * `ctx.http` for HTTP. A sink that carries feedback out marks its requests with `extensions=EGRESS` (from `obsei.core.context`) and calls `ctx.egress.check(url)` when it is created, so the egress policy covers every hop. * `ctx.author(handle)` for authors, so handles are pseudonymised (or dropped without a salt). Never put handles in text, context or URLs. * `ctx.chat(name)` and `ctx.embedder(name)` for the model endpoints configured under `llms`. Rules for plugins in this repository: * Typed (mypy strict) and tested with `httpx.MockTransport`; no network in tests. * Secrets only through `*_env` config fields; never log or store them. * Respect each platform’s terms. Scrapers stay opt-in and clearly labelled.