> ## Documentation Index
> Fetch the complete documentation index at: https://raindrop.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Signals with Zero Data Retention (ZDR)

> Classify production events without Raindrop retaining their content: signals built from labeled examples evaluate raw payloads in memory and store only sanitized identity shells.

<Note>
  ZDR Signals is an experimental preview. It must be enabled for your organization;
  organizations without preview access receive `404`. Contact
  [support@raindrop.ai](mailto:support@raindrop.ai) to join the preview.
</Note>

Signals turn examples of an important agent behavior into a classifier that runs on every event you
send.

**Zero data retention is the point of this surface.** When you track production events here,
Raindrop classifies the full event — `data`, `trace`, tool calls — **in memory** and never stores
any of it. What persists is a sanitized identity shell: the event ID, timestamp, an opaque user ID,
and the explicit `properties` you choose to send; events that match a signal additionally record
the matched signal IDs. There is one deliberate exception: the **labeled training examples you
upload to build a signal are retained** — that is what the classifier is built and validated
against, and you control what goes into them. The [retention table](#understand-retention-first)
below is the complete contract.

This guide builds **Angry customer**, a signal for a support assistant that should catch messages
where the customer is angry, frustrated, or threatening to escalate — chargebacks, lawyers, public
complaints — while leaving routine questions alone.

```text theme={null}
Match:    You people charged me twice AGAIN. Fix it now or I dispute every charge.
No match: Hi! How do I export my data to CSV?
```

## Choose how you work

Every step in this guide is available on four surfaces. They drive the same signals, and the
results land in the same place.

| Surface       | What it is                                                                   | Best for                                             |
| ------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------- |
| **CLI**       | One self-contained file; polls builds and receipts for you                   | Day-to-day use from a terminal, scripting            |
| **API**       | Plain HTTPS + JSON against `https://query.raindrop.ai`                       | Production integrations                              |
| **Local UI**  | A browser playground served by the CLI on your machine; your key stays local | Exploring the workflow visually without any setup    |
| **Dashboard** | [app.raindrop.ai](https://app.raindrop.ai) — the same signals, fully hosted  | CSV/spreadsheet uploads, reviewing results, refining |

## What you will build

<Steps>
  <Step title="Teach the behavior">
    Upload labeled examples. These are retained — they are what the classifier is built and
    validated against.
  </Step>

  <Step title="Prove it before production">
    Exercise the active classifier in the Playground or with the synchronous evaluate endpoint.
    These tests are not stored.
  </Step>

  <Step title="Evaluate production events privately">
    Fire-and-forget up to 1,000 events per `batch` request. Raw `data` and `trace` are
    evaluated in memory; poll the receipt for per-event results.
  </Step>

  <Step title="Review and improve matches">
    Inspect sanitized event shells in Raindrop — matches carry signal membership — then add edge
    cases to create a new immutable signal version.
  </Step>
</Steps>

## Understand retention first

Training and production have different retention contracts. Use synthetic or approved examples for
training, then use ZDR tracking for sensitive production content.

| Operation               | Raw `data` and `trace` | What remains in Raindrop                                                             |
| ----------------------- | ---------------------- | ------------------------------------------------------------------------------------ |
| Dataset upload          | **Retained**           | Examples, labels, properties, event metadata, and traces used to build the signal    |
| Playground / `evaluate` | **Not retained**       | Nothing from the evaluation payload                                                  |
| `batch` — no match      | **Not retained**       | Sanitized identity shell: event ID, timestamp, opaque user ID, explicit `properties` |
| `batch` — match         | **Not retained**       | The same sanitized shell plus the matched signal IDs                                 |

`batch` batches await classification in a memory-only staging buffer: encrypted while
queued, capped at 15 minutes, destroyed on read, and never written to disk. Payloads otherwise
exist only during classification, for the duration of the run.

<Warning>
  `properties` are part of the persisted event shell. Put only non-sensitive dimensions there, such
  as product surface, release, or an opaque channel. Keep transcripts, prompts, answers, names,
  emails, and source URLs inside `data` or `trace`.
</Warning>

## Before you start

You need:

* Preview access for your organization
* An [Organization API Key](/docs/query-api/api-reference#authentication) from
  [Settings → API Keys](https://app.raindrop.ai/settings?tab=api-keys)
* Your project slug, if you use more than one project
* The [Example files](#example-files) saved locally

## Set up your surface

<Tabs>
  <Tab title="CLI">
    Install with one command. The installer prompts for your Organization API Key, downloads the
    CLI, verifies its checksum, and signs you in:

    ```bash theme={null}
    curl -fsSL https://query.raindrop.ai/v1/cli/install.sh | bash
    ```

    In CI and other non-interactive environments, set `RAINDROP_API_KEY` and the installer uses
    it without prompting:

    ```bash theme={null}
    export RAINDROP_API_KEY="YOUR_API_KEY"
    curl -fsSL https://query.raindrop.ai/v1/cli/install.sh | bash
    ```

    The CLI requires **Node.js 20 or newer** and installs to `~/.local/bin/raindrop-signals`.
    To update, re-run the installer.

    Credentials are saved to `~/.config/raindrop-signals/config.json`, so commands work with no
    environment variables. Each value resolves by precedence
    **flag → `RAINDROP_*` env var → saved config → default**:

    ```bash theme={null}
    raindrop-signals config show   # effective values (key masked) and their sources
    raindrop-signals login         # rotate your key
    raindrop-signals logout        # delete the saved config
    ```
  </Tab>

  <Tab title="API">
    All endpoints use the Query API base URL:

    ```text theme={null}
    https://query.raindrop.ai
    ```

    Authenticate every request with `Authorization: Bearer <Organization API Key>`. Single-project
    organizations can omit `X-Raindrop-Project-Id`; otherwise send the project slug explicitly.

    The `curl` examples in this guide assume two environment variables:

    ```bash theme={null}
    export RAINDROP_API_KEY="YOUR_API_KEY"
    export RAINDROP_PROJECT_ID="default"
    ```

    Every successful response uses a `{ "data": ... }` envelope; errors use
    `{ "error": { "code": "...", "message": "..." } }`.
  </Tab>

  <Tab title="Local UI">
    The Local UI is a browser playground served by the CLI on your machine. Install the CLI (see
    the **CLI** tab), then:

    ```bash theme={null}
    raindrop-signals ui        # serves http://127.0.0.1:4400; --port and --no-open available
    ```

    Your API key stays on your machine — the page talks to a local proxy, and the key is never sent
    to the browser. Every action is badged **RETAINED** (training data) or **ZDR** (sanitized shell
    only), so the retention contract is visible at each step, and results link straight to the
    matching dashboard pages.
  </Tab>

  <Tab title="Dashboard">
    Sign in at [app.raindrop.ai](https://app.raindrop.ai). With the preview enabled, the primary
    navigation focuses on **Events**, **Datasets**, and **Signals** — everything in this guide
    happens on those three pages. No other setup is required; the dashboard also handles CSV,
    JSONL, TSV, and spreadsheet uploads, which the other surfaces do not.
  </Tab>
</Tabs>

## Example files

Every `--file` flag and `curl -d @file` example in this guide refers to one of the five files below
by its exact name. The contents are self-contained fictional support conversations, so you need
nothing else to follow along. Labels use exact lowercase values everywhere: `match` and `no_match`.

Download all five into the current directory:

```bash theme={null}
for f in training-events.json evaluate-match.json evaluate-no-match.json \
         events-initial.json refinement-events.json; do
  curl -fsSLO "https://www.raindrop.ai/docs/files/signals-api/$f"
done
```

<Tip>
  **Re-running the tutorial in the same organization?** Give each run unique event IDs so its
  events don't attach to a previous run's stored shells, and send a fresh `Idempotency-Key` on
  repeat `batch` calls (see [Make retries safe](#make-retries-safe)):

  ```bash theme={null}
  perl -pi -e "s/demo-/demo-$(date +%H%M%S)-/g" ./*.json
  ```
</Tip>

<AccordionGroup>
  <Accordion title="training-events.json">
    The labeled set that builds the signal: 4 `match`, 4 `no_match`, and 1 unlabeled example
    (`label` is optional for dataset storage; unlabeled examples do not contribute to validation
    metrics).

    ```json training-events.json theme={null}
    {
      "events": [
        {
          "event_id": "demo-training-angry-1",
          "label": "match",
          "properties": {
            "account_tier": "enterprise",
            "channel": "support_chat"
          },
          "data": {
            "input": "This is the third time your app charged me twice. I am furious and I'm filing a chargeback.",
            "output": "I'm sorry about the duplicate charge — let me fix that right away."
          },
          "trace": [
            {
              "type": "user_message",
              "content": "This is the third time your app charged me twice. I am furious and I'm filing a chargeback.",
              "span_id": "msg-1"
            },
            {
              "type": "span",
              "name": "triage_complaint",
              "span_type": "agent_step",
              "status": "ok",
              "duration_ms": 640,
              "span_id": "step-1",
              "parent_span_id": "msg-1"
            },
            {
              "type": "tool_call",
              "name": "fetch_billing_history",
              "status": "completed",
              "duration_ms": 130,
              "input": {
                "user": "customer-207"
              },
              "output": {
                "duplicate_charges": 2
              },
              "span_id": "tool-1",
              "parent_span_id": "step-1"
            },
            {
              "type": "assistant_text",
              "content": "I'm sorry about the duplicate charge — let me fix that right away.",
              "span_id": "reply-1",
              "parent_span_id": "step-1"
            }
          ]
        },
        {
          "event_id": "demo-training-angry-2",
          "label": "match",
          "properties": {
            "account_tier": "enterprise",
            "channel": "support_chat"
          },
          "data": {
            "input": "Your support is useless. I've been waiting two weeks. Absolutely unacceptable.",
            "output": "I apologize for the wait. Let me escalate this now."
          }
        },
        {
          "event_id": "demo-training-angry-3",
          "label": "match",
          "properties": {
            "account_tier": "pro",
            "channel": "support_chat"
          },
          "data": {
            "input": "I want to speak to a manager RIGHT NOW or I'm posting about this everywhere.",
            "output": "I understand your frustration — connecting you to a supervisor."
          }
        },
        {
          "event_id": "demo-training-angry-4",
          "label": "match",
          "properties": {
            "account_tier": "enterprise",
            "channel": "support_chat"
          },
          "data": {
            "input": "You people charged me twice AGAIN. Fix it now or I dispute every charge.",
            "output": "I'm so sorry — refunding the duplicate charge immediately."
          }
        },
        {
          "event_id": "demo-training-calm-1",
          "label": "no_match",
          "properties": {
            "account_tier": "starter",
            "channel": "support_chat"
          },
          "data": {
            "input": "Hey, quick question — can I change my delivery address?",
            "output": "Of course! What's the new address?"
          }
        },
        {
          "event_id": "demo-training-calm-2",
          "label": "no_match",
          "properties": {
            "account_tier": "pro",
            "channel": "support_chat"
          },
          "data": {
            "input": "Thanks so much, that fixed it!",
            "output": "Happy to help — anything else?"
          }
        },
        {
          "event_id": "demo-training-calm-3",
          "label": "no_match",
          "properties": {
            "account_tier": "pro",
            "channel": "support_chat"
          },
          "data": {
            "input": "Could you explain the difference between the Pro and Team plans?",
            "output": "Sure — Pro is for individuals, Team adds shared workspaces."
          }
        },
        {
          "event_id": "demo-training-calm-4",
          "label": "no_match",
          "properties": {
            "account_tier": "starter",
            "channel": "support_chat"
          },
          "data": {
            "input": "Hi! How do I export my data to CSV?",
            "output": "Go to Settings → Export and pick CSV."
          },
          "trace": [
            {
              "type": "user_message",
              "content": "Hi! How do I export my data to CSV?",
              "span_id": "msg-1"
            },
            {
              "type": "span",
              "name": "kb_lookup",
              "span_type": "retrieval",
              "status": "ok",
              "duration_ms": 88,
              "input": {
                "query": "export csv"
              },
              "output": {
                "passages": 2
              },
              "span_id": "ret-1",
              "parent_span_id": "msg-1"
            },
            {
              "type": "assistant_text",
              "content": "Go to Settings → Export and pick CSV.",
              "span_id": "reply-1",
              "parent_span_id": "msg-1"
            }
          ]
        },
        {
          "event_id": "demo-training-unlabeled-1",
          "properties": {
            "account_tier": "starter",
            "channel": "support_chat"
          },
          "data": {
            "input": "The app is not behaving how I expected today.",
            "output": "Can you tell me what went wrong?"
          }
        }
      ]
    }
    ```
  </Accordion>

  <Accordion title="evaluate-match.json">
    A single event that should classify as `match` against the built signal.

    ```json evaluate-match.json theme={null}
    {
      "properties": {
        "account_tier": "enterprise",
        "channel": "support_chat"
      },
      "data": {
        "input": "I am done with this garbage service. Refund me or my lawyer calls.",
        "output": "I'm very sorry — processing your refund now."
      },
      "trace": [
        {
          "type": "user_message",
          "content": "I am done with this garbage service. Refund me or my lawyer calls.",
          "span_id": "msg-1"
        },
        {
          "type": "assistant_text",
          "content": "I'm very sorry — processing your refund now.",
          "span_id": "reply-1",
          "parent_span_id": "msg-1"
        }
      ]
    }
    ```
  </Accordion>

  <Accordion title="evaluate-no-match.json">
    A single event that should classify as `no_match`.

    ```json evaluate-no-match.json theme={null}
    {
      "properties": {
        "account_tier": "starter",
        "channel": "support_chat"
      },
      "data": {
        "input": "What file formats do you support for uploads?",
        "output": "We support CSV, JSON, and Parquet."
      }
    }
    ```
  </Accordion>

  <Accordion title="events-initial.json">
    A ZDR batch of two production events: one matching (angry) and one non-matching (calm).

    ```json events-initial.json theme={null}
    {
      "events": [
        {
          "event_id": "demo-zdr-angry-1",
          "timestamp": "2026-07-17T09:00:00Z",
          "user_id": "customer-207",
          "properties": {
            "account_tier": "enterprise",
            "channel": "support_chat"
          },
          "data": {
            "input": "You people charged me twice AGAIN. Fix it now or I dispute every charge.",
            "output": "I'm so sorry — refunding the duplicate charge immediately."
          },
          "trace": [
            {
              "type": "user_message",
              "content": "You people charged me twice AGAIN. Fix it now or I dispute every charge.",
              "span_id": "msg-1"
            },
            {
              "type": "span",
              "name": "handle_complaint",
              "span_type": "agent_step",
              "status": "ok",
              "duration_ms": 910,
              "span_id": "step-1",
              "parent_span_id": "msg-1"
            },
            {
              "type": "tool_call",
              "name": "issue_refund",
              "status": "completed",
              "duration_ms": 402,
              "input": {
                "order_id": "5521",
                "amount_usd": 120.0
              },
              "output": {
                "ok": true
              },
              "span_id": "tool-1",
              "parent_span_id": "step-1"
            },
            {
              "type": "assistant_text",
              "content": "I'm so sorry — refunding the duplicate charge immediately.",
              "span_id": "reply-1",
              "parent_span_id": "step-1"
            }
          ]
        },
        {
          "event_id": "demo-zdr-calm-1",
          "timestamp": "2026-07-17T09:00:05Z",
          "user_id": "customer-402",
          "properties": {
            "account_tier": "starter",
            "channel": "support_chat"
          },
          "data": {
            "input": "Hi! How do I export my data to CSV?",
            "output": "Go to Settings → Export and pick CSV."
          }
        }
      ]
    }
    ```
  </Accordion>

  <Accordion title="refinement-events.json">
    Edge cases for a second signal version: sarcasm as `match`, neutral outage reports as `no_match`.

    ```json refinement-events.json theme={null}
    {
      "events": [
        {
          "event_id": "demo-training-sarcastic-1",
          "label": "match",
          "properties": {
            "account_tier": "pro",
            "channel": "support_chat"
          },
          "data": {
            "input": "Oh great, the app crashed again. Truly world-class engineering.",
            "output": "I'm sorry about the crash — let me look into it."
          }
        },
        {
          "event_id": "demo-training-sarcastic-2",
          "label": "match",
          "properties": {
            "account_tier": "enterprise",
            "channel": "support_chat"
          },
          "data": {
            "input": "Wow, another outage. Really getting my money's worth here.",
            "output": "I apologize — the service is back up now."
          },
          "trace": [
            {
              "type": "user_message",
              "content": "Wow, another outage. Really getting my money's worth here.",
              "span_id": "msg-1"
            },
            {
              "type": "tool_call",
              "name": "check_status_page",
              "status": "completed",
              "duration_ms": 74,
              "input": {
                "service": "api"
              },
              "output": {
                "status": "recovered"
              },
              "span_id": "tool-1",
              "parent_span_id": "msg-1"
            },
            {
              "type": "assistant_text",
              "content": "I apologize — the service is back up now.",
              "span_id": "reply-1",
              "parent_span_id": "msg-1"
            }
          ]
        },
        {
          "event_id": "demo-training-neutral-1",
          "label": "no_match",
          "properties": {
            "account_tier": "starter",
            "channel": "support_chat"
          },
          "data": {
            "input": "The app crashed — can you help me recover my draft?",
            "output": "Yes, drafts autosave every minute. Let me restore it."
          }
        },
        {
          "event_id": "demo-training-neutral-2",
          "label": "no_match",
          "properties": {
            "account_tier": "pro",
            "channel": "support_chat"
          },
          "data": {
            "input": "Is there an outage right now? My dashboard won't load.",
            "output": "Yes, we're recovering from a brief outage — should be back shortly."
          }
        }
      ]
    }
    ```
  </Accordion>
</AccordionGroup>

## 1. Create the signal

Creating a signal retains the labeled examples (that is the training contract), infers the
input/output field mapping, generates a classifier, and scores it against every labeled example.
The signal activates automatically only when the validation set has zero false positives, false
negatives, and indeterminate outcomes; otherwise the build ends `requires_action` and you refine
with more examples.

<Tabs>
  <Tab title="CLI">
    One call: the labeled events attach inline, become a retained dataset, and the build is
    queued. `--watch` polls the build job until it finishes (usually a minute or two):

    ```bash theme={null}
    raindrop-signals create \
      --name "Angry customer" \
      --description "Matches when the user message expresses anger, frustration, or a threat to escalate" \
      --file training-events.json \
      --watch
    ```

    Copy the `signal_id` from the output into a shell variable — later steps use it:

    ```bash theme={null}
    export SIGNAL_ID="<signal_id from the create output>"

    raindrop-signals list               # every API-built signal in the project
    raindrop-signals get "$SIGNAL_ID"   # definition, versions, validation metrics
    ```
  </Tab>

  <Tab title="API">
    Send the labeled events inline to create the retained dataset and queue the build in one
    request:

    ```bash theme={null}
    curl -X POST https://query.raindrop.ai/v1/signals \
      -H "Authorization: Bearer $RAINDROP_API_KEY" \
      -H "X-Raindrop-Project-Id: $RAINDROP_PROJECT_ID" \
      -H "Content-Type: application/json" \
      -d @create-signal.json
    ```

    `create-signal.json`:

    ```json theme={null}
    {
      "name": "Angry customer",
      "description": "Matches when the user message expresses anger, frustration, or a threat to escalate (chargeback, lawyer, public complaint)",
      "dataset": {
        "name": "Angry customer examples",
        "events": [
          {
            "label": "match",
            "data": {
              "input": "This is the third time your app charged me twice. I am furious and I'm filing a chargeback.",
              "output": "I'm sorry about the duplicate charge — let me fix that right away."
            }
          },
          {
            "label": "no_match",
            "data": {
              "input": "Hey, quick question — can I change my delivery address?",
              "output": "Of course! What's the new address?"
            }
          }
        ]
      }
    }
    ```

    Create returns `202 Accepted` with the IDs needed to follow the asynchronous build:

    ```json theme={null}
    {
      "data": {
        "signal_id": "0b4f0000-0000-4000-8000-000000000000",
        "job_id": "9c2a0000-0000-4000-8000-000000000000",
        "status": "queued",
        "dataset_id": "5fc80000-0000-4000-8000-000000000000"
      }
    }
    ```

    Poll `GET /v1/signal-jobs/{job_id}` until it reaches `succeeded`, `failed`, or
    `requires_action`, then read
    `GET /v1/signals/{signal_id}/definition` for status, field mapping, active version, immutable
    version history, classifier source, and validation metrics.
  </Tab>

  <Tab title="Local UI">
    Open **Create signal** in the sidebar. Drop `training-events.json` into the upload area — the
    page confirms what it parsed ("9 events · 4 match · 4 no\_match · 1 unlabeled") — then name the
    signal, add the description, and submit. The build panel tracks the job live (queued → running
    → succeeded) and links to the new signal when version 1 is ready. The upload is badged
    **RETAINED**: these examples become the signal's training dataset.
  </Tab>

  <Tab title="Dashboard">
    The dashboard accepts CSV, JSON, JSONL, TSV, and spreadsheet files, maps columns automatically,
    and suggests a signal definition from your examples.

    <Steps>
      <Step title="Open the builder">
        Go to **Signals** and select **New Signal**.
      </Step>

      <Step title="Upload examples">
        Drop `training-events.json` into the **Examples** step. Confirm the match, no-match, and
        unlabeled counts before uploading — this file shows 4 match, 4 no match, and 1 unlabeled.

        <Frame>
          <img src="https://mintcdn.com/dawn-a6c57108/Wnn0zDxCgBmUm_pg/images/signals-api/create-examples.jpg?fit=max&auto=format&n=Wnn0zDxCgBmUm_pg&q=85&s=64fabd26b967089bd3b9090e7f22184d" alt="New Signal examples step with a labeled dataset ready to upload" width="1280" height="720" data-path="images/signals-api/create-examples.jpg" />
        </Frame>
      </Step>

      <Step title="Review the definition">
        Raindrop suggests a name and description from the examples. Make the decision boundary
        explicit:

        ```text theme={null}
        Match when the customer message expresses anger, frustration, or a threat to escalate —
        chargebacks, lawyers, managers, public complaints. Do not match calm questions, plan
        inquiries, thanks, or routine how-to requests.
        ```

        <Frame>
          <img src="https://mintcdn.com/dawn-a6c57108/Wnn0zDxCgBmUm_pg/images/signals-api/create-details.jpg?fit=max&auto=format&n=Wnn0zDxCgBmUm_pg&q=85&s=218bd9c1063338f5472d2346c1e513e2" alt="Signal details step defining the signal" width="1280" height="720" data-path="images/signals-api/create-details.jpg" />
        </Frame>
      </Step>

      <Step title="Build and activate">
        Select **Create signal** and wait for the build (usually a minute or two).
      </Step>
    </Steps>
  </Tab>
</Tabs>

<Warning>
  `GET /v1/signals/{signalId}/definition` (and the CLI's `get` and `refine --watch` output) does
  not return your dataset events as a separate field, but the generated `classifier_code` it
  exposes can reference or quote patterns — sometimes verbatim phrases — from your training
  examples. Give these responses the same need-to-know handling as the retained dataset itself.
</Warning>

Once active, the signal's detail page in the dashboard shows matching event volume and three
controls: **Configure** (definition, retained dataset reference, inferred field mapping, immutable
versions, generated classifier code, validation metrics), **Playground**, and **Refine**.

<Frame>
  <img src="https://mintcdn.com/dawn-a6c57108/Wnn0zDxCgBmUm_pg/images/signals-api/configure-signal.jpg?fit=max&auto=format&n=Wnn0zDxCgBmUm_pg&q=85&s=136b301228ec4f8270522c4a644fe350" alt="Configure Signal panel with field mapping and validation metrics" width="1164" height="655" data-path="images/signals-api/configure-signal.jpg" />
</Frame>

## 2. Test it before production

Test the active classifier synchronously with payloads that are **never stored** — nothing from an
evaluation request persists, on any surface. `outcome` is `match`, `no_match`, or `indeterminate`.

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    raindrop-signals evaluate "$SIGNAL_ID" --file evaluate-match.json
    raindrop-signals evaluate "$SIGNAL_ID" --file evaluate-no-match.json
    ```

    A file may hold a single `{ "data": ... }` event or multiple events (`{ "events": [...] }` or a
    bare array); the CLI batches them 100 at a time and prints one outcome per event.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    curl -X POST "https://query.raindrop.ai/v1/signals/$SIGNAL_ID/evaluate" \
      -H "Authorization: Bearer $RAINDROP_API_KEY" \
      -H "X-Raindrop-Project-Id: $RAINDROP_PROJECT_ID" \
      -H "Content-Type: application/json" \
      -d @evaluate-match.json
    ```

    ```json theme={null}
    {
      "data": {
        "signal_id": "0b4f0000-0000-4000-8000-000000000000",
        "version": 1,
        "outcome": "match"
      }
    }
    ```

    To evaluate several payloads in one classification pass, send `events` instead of `data` — up
    to 100 per request, each with an optional `event_id` echo:

    ```bash theme={null}
    curl -X POST "https://query.raindrop.ai/v1/signals/$SIGNAL_ID/evaluate" \
      -H "Authorization: Bearer $RAINDROP_API_KEY" \
      -H "X-Raindrop-Project-Id: $RAINDROP_PROJECT_ID" \
      -H "Content-Type: application/json" \
      -d '{
        "events": [
          { "event_id": "angry", "data": { "input": "Refund me or my lawyer calls." } },
          { "event_id": "calm", "data": { "input": "Which plan includes SSO?" } }
        ]
      }'
    ```

    ```json theme={null}
    {
      "data": {
        "signal_id": "0b4f0000-0000-4000-8000-000000000000",
        "version": 1,
        "results": [
          { "index": 0, "event_id": "angry", "outcome": "match" },
          { "index": 1, "event_id": "calm", "outcome": "no_match" }
        ]
      }
    }
    ```
  </Tab>

  <Tab title="Local UI">
    Open **Evaluate**, pick the signal in the toolbar, and add events — type them in, use
    **import a JSON events file**, or drag a file (like `evaluate-match.json`) onto the table; a
    single event object or a multi-event file both work. Select **Run**: the toolbar shows live
    progress and elapsed time, and each row gets a clear **MATCH** / **NO MATCH** result with its
    response time. Optionally set an **Expected** outcome per row — disagreements are highlighted
    and counted in the summary — then use **Save as refinement** to send the labeled rows back as
    training data for the next version. Only that save step stores anything; evaluation itself
    is ZDR.
  </Tab>

  <Tab title="Dashboard">
    Open the signal and select **Playground**. Add one expected match ("This is outrageous — I've
    been double charged three times and I'm calling my bank!") and one expected no-match ("Hi! Can
    you tell me which plan includes SSO?"), then select **Run 2**. A green result means the
    classifier outcome agrees with your expected label.

    <Frame>
      <img src="https://mintcdn.com/dawn-a6c57108/Wnn0zDxCgBmUm_pg/images/signals-api/playground.jpg?fit=max&auto=format&n=Wnn0zDxCgBmUm_pg&q=85&s=0c82943a23bd94d619cc874aef0aa1ae" alt="Signal Playground showing passing match and no-match examples" width="1164" height="655" data-path="images/signals-api/playground.jpg" />
    </Frame>

    You can download the Playground rows as a labeled JSON dataset and reuse them in a future
    refinement.
  </Tab>
</Tabs>

Evaluation runs synchronously with an internal 25-second deadline. Under heavy load it can return a
`503` with the `{ "error": { "code": "SERVICE_UNAVAILABLE", ... } }` envelope — nothing was
persisted, so retry with bounded backoff. The CLI and Local UI retry transient failures for you.

## 3. Classify production events with ZDR

Use ZDR tracking for production classification. It is **fire-and-forget**: a request carries one to
1,000 events, is accepted immediately with a receipt, and classification runs asynchronously
against every active API-built signal (or an explicit list of up to twenty signal IDs). Poll the
receipt for per-event results; sanitized shells land in the Events view whether or not you poll.

While a batch waits for classification it exists only in a memory-only staging buffer — encrypted,
capped at 15 minutes, never written to disk. The classifier reads it and the staged copy is
destroyed. If the buffer is lost before classification runs, the receipt finalizes `failed` and
you resend the batch.

Every tracked event persists a **sanitized identity shell** — `event_id`, timestamp, user ID, and
your explicit `properties` — whether or not it matches. Matches additionally record the matched
signal IDs. `data` and `trace` are never stored for any event.

Classification reads `data` and `trace`, and classifiers can also reference `properties` as
explicit dimensions. The persisted shell identity comes from the top-level `event_id`, `timestamp`,
and `user_id`. If `timestamp` is omitted, the shell records the **accept time** — the server time
when Raindrop received the request — and if `user_id` is omitted it is `anonymous`. Because
classification runs asynchronously, always send an explicit `timestamp` for accurate event
timelines.

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    raindrop-signals batch \
      --file events-initial.json \
      --signal-ids "$SIGNAL_ID" \
      --watch
    ```

    `batch` submits the batch and prints the receipt ID immediately; `--watch` polls until the
    receipt is terminal and prints a summary. Without `--watch`, check the receipt later — use
    `--json` for per-event outcomes:

    ```bash theme={null}
    export RECEIPT_ID="<receipt_id from the batch output>"

    raindrop-signals batch-receipt "$RECEIPT_ID"          # summary
    raindrop-signals batch-receipt "$RECEIPT_ID" --json   # per-event match results
    ```

    The CLI fills a missing `event_id` with a deterministic content hash and splits large files
    into 1,000-event requests, each with a derived `Idempotency-Key`, so re-running the same file
    is idempotent (see [Make retries safe](#make-retries-safe)).
  </Tab>

  <Tab title="API">
    Every event needs a non-empty `event_id`, unique inside the batch. If you provide `signal_ids`,
    an unknown or inactive ID rejects the request; omit the field to evaluate every active
    API-built signal. The example below is [`events-initial.json`](#example-files) with
    `signal_ids` added:

    ```bash theme={null}
    curl -X POST https://query.raindrop.ai/v1/events/batch \
      -H "Authorization: Bearer $RAINDROP_API_KEY" \
      -H "X-Raindrop-Project-Id: $RAINDROP_PROJECT_ID" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: angry-customer-initial-$(date +%s)" \
      -d '{
        "events": [
          {
            "event_id": "demo-zdr-angry-1",
            "timestamp": "2026-07-17T09:00:00Z",
            "user_id": "customer-207",
            "properties": { "account_tier": "enterprise", "channel": "support_chat" },
            "data": {
              "input": "You people charged me twice AGAIN. Fix it now or I dispute every charge.",
              "output": "I'\''m so sorry — refunding the duplicate charge immediately."
            },
            "trace": [
              {
                "type": "user_message",
                "content": "You people charged me twice AGAIN. Fix it now or I dispute every charge."
              },
              {
                "type": "tool_call",
                "name": "issue_refund",
                "status": "completed",
                "input": { "order_id": "5521", "amount_usd": 120.0 },
                "output": { "ok": true }
              },
              {
                "type": "assistant_text",
                "content": "I'\''m so sorry — refunding the duplicate charge immediately."
              }
            ]
          },
          {
            "event_id": "demo-zdr-calm-1",
            "timestamp": "2026-07-17T09:00:05Z",
            "user_id": "customer-402",
            "properties": { "account_tier": "starter", "channel": "support_chat" },
            "data": {
              "input": "Hi! How do I export my data to CSV?",
              "output": "Go to Settings → Export and pick CSV."
            }
          }
        ],
        "signal_ids": ["0b4f0000-0000-4000-8000-000000000000"]
      }'
    ```

    The API accepts the batch immediately — no waiting on classification:

    ```json theme={null}
    {
      "data": {
        "receipt_id": "0f6d2c9e-6d67-4f6e-9f3f-2f1b7f9f5a10",
        "status": "accepted",
        "events_total": 2,
        "results": [],
        "error": null,
        "created_at": "2026-07-17T09:00:00.500Z",
        "processed_at": null
      }
    }
    ```

    Poll the receipt until its status is terminal:

    ```bash theme={null}
    curl -fsS "https://query.raindrop.ai/v1/events/batch/receipts/$RECEIPT_ID" \
      -H "Authorization: Bearer $RAINDROP_API_KEY" \
      -H "X-Raindrop-Project-Id: $RAINDROP_PROJECT_ID" | jq
    ```

    When complete, the receipt reports per-event outcomes — the angry message matches and the calm
    one does not:

    ```json theme={null}
    {
      "data": {
        "receipt_id": "0f6d2c9e-6d67-4f6e-9f3f-2f1b7f9f5a10",
        "status": "completed",
        "events_total": 2,
        "results": [
          {
            "event_id": "demo-zdr-angry-1",
            "matched_signal_ids": ["0b4f0000-0000-4000-8000-000000000000"],
            "indeterminate_signal_ids": []
          },
          {
            "event_id": "demo-zdr-calm-1",
            "matched_signal_ids": [],
            "indeterminate_signal_ids": []
          }
        ],
        "error": null,
        "created_at": "2026-07-17T09:00:00.500Z",
        "processed_at": "2026-07-17T09:00:07.212Z"
      }
    }
    ```
  </Tab>

  <Tab title="Local UI">
    Open **Batch ZDR** and drop in `events-initial.json`. The page shows exactly what will and will
    not be stored per event before you send, then submits the batch. The receipt panel polls live:
    each event row shows its match / no-match outcome as classification completes, side by side
    with "what was stored — sanitized shell" and "what was never stored" panels.
  </Tab>

  <Tab title="Dashboard">
    For a manual smoke test, go to **Events**, select **Upload events**, and choose a CSV, JSON,
    JSONL, TSV, or spreadsheet. The dashboard maps columns, sends batches of ten against all active
    API-built signals, and reports which events matched; every event persists its sanitized shell.

    This manual upload has no signal selector or idempotency key and does not forward a
    `properties` column — use the API or CLI for controlled production ingestion and persisted
    shell dimensions.

    <Frame>
      <img src="https://mintcdn.com/dawn-a6c57108/Wnn0zDxCgBmUm_pg/images/signals-api/zdr-receipt.jpg?fit=max&auto=format&n=Wnn0zDxCgBmUm_pg&q=85&s=88a4a62549bc4fbe0489a91e2f99add7" alt="ZDR event upload receipt showing per-event match results with sanitized shells persisted" width="1164" height="655" data-path="images/signals-api/zdr-receipt.jpg" />
    </Frame>
  </Tab>
</Tabs>

### How long classification takes

Small batches usually reach a terminal status in **5–60 seconds**. Classification is queued per
organization, so under load a batch can take several minutes — that is normal queueing, not lost
data.

Use ZDR tracking as designed: fire-and-forget. Submit the batch, then poll the receipt later or
read the sanitized shells from the Events view once they appear — do not block a request on a
receipt reaching `completed`. A batch that is not classified within the 15-minute staging window
finalizes `failed`; resend it.

### Receipt statuses

* `accepted` — the batch is staged and classification is pending; keep polling.
* `completed` — the receipt carries the final per-event results and all sanitized identity shells
  were stored.
* `failed` — the batch was not classified: the classifier errored, exhausted its retries, or the
  staged batch aged past the 15-minute staging window. Resend with a fresh `Idempotency-Key`.
* `expired` — treat the same as `failed`: resend with a fresh `Idempotency-Key`.
* Receipts expire 24 hours after acceptance; after that the endpoint returns 404.
* An empty `matched_signal_ids` array is a no-match; the event still stores its sanitized identity
  shell.
* `indeterminate_signal_ids` identifies classifiers that timed out or errored; indeterminate is
  not a match.

Treat transient `5xx` responses and network errors on the receipt GET as retryable, with the same
bounded backoff you use for `429` — the receipt is durable for 24 hours, so a failed poll never
loses results. A sustained `503` on the `batch` accept call is staging backpressure: the batch
was not accepted, so resend it.

### Make retries safe

Send an `Idempotency-Key` header of at most 256 characters. A repeat request with the same key
returns the existing receipt (whatever its status) for 24 hours instead of accepting a duplicate
batch. Reuse a key only for the same logical batch; after an `expired` or `failed` receipt, resend
with a fresh key.

The CLI splits `batch` files into 1,000-event requests automatically and sends a per-request
`Idempotency-Key` derived from the batch content **and the signal scope** — the same events sent
with different `--signal-ids` get distinct keys (override the base with `--idempotency-key`), so
re-running the same file is retry-safe.

## 4. Review the results

Tracked-event shells appear in the dashboard's **Events** view regardless of which surface sent
them; matched events also appear on each matching signal's detail page.

<Tabs>
  <Tab title="CLI">
    Read per-event outcomes from the receipt for any batch from the last 24 hours:

    ```bash theme={null}
    raindrop-signals batch-receipt "$RECEIPT_ID" --json
    ```

    To browse the stored shells themselves, use the dashboard's Events view or `GET /v1/events`.
  </Tab>

  <Tab title="API">
    Use `GET /v1/events` and the existing Query API filters when you need to consume event shells
    programmatically — they are regular events with no retained `data` or `trace`.
  </Tab>

  <Tab title="Dashboard">
    The Events view shows the event ID, timestamp, opaque user ID, and properties; matched events
    carry their signal membership. Opening a row keeps the detail panel on properties because there
    is no retained conversation, prompt, answer, or trace to display.

    <Frame>
      <img src="https://mintcdn.com/dawn-a6c57108/Wnn0zDxCgBmUm_pg/images/signals-api/zdr-event-detail.jpg?fit=max&auto=format&n=Wnn0zDxCgBmUm_pg&q=85&s=5ef6c73370bbe08778820e38279531ec" alt="Sanitized matched event detail showing identifiers and safe properties without raw content" width="1164" height="655" data-path="images/signals-api/zdr-event-detail.jpg" />
    </Frame>
  </Tab>
</Tabs>

## 5. Refine the decision boundary

Refine when a production match is wrong or a known miss should have matched. Add both the failure
and a nearby hard negative whenever possible. The angry-customer scenario refines on sarcasm —
anger the first version misses because nothing is shouted:

```text theme={null}
Match:    Wow, another outage. Really getting my money's worth here.
No match: Is there an outage right now? My dashboard won't load.
```

<Tabs>
  <Tab title="CLI">
    ```bash theme={null}
    raindrop-signals refine "$SIGNAL_ID" \
      --description "Sarcasm and passive-aggressive complaints count as anger too" \
      --file refinement-events.json \
      --base-version 1 \
      --watch
    ```
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    curl -X POST "https://query.raindrop.ai/v1/signals/$SIGNAL_ID/refinements" \
      -H "Authorization: Bearer $RAINDROP_API_KEY" \
      -H "X-Raindrop-Project-Id: $RAINDROP_PROJECT_ID" \
      -H "Content-Type: application/json" \
      -d '{
        "description": "Sarcasm and passive-aggressive complaints count as anger too, even without exclamation marks or explicit threats",
        "base_version": 1,
        "dataset": {
          "events": [
            {
              "label": "match",
              "data": {
                "input": "Oh great, the app crashed again. Truly world-class engineering.",
                "output": "I'\''m sorry about the crash — let me look into it."
              }
            },
            {
              "label": "no_match",
              "data": {
                "input": "The app crashed — can you help me recover my draft?",
                "output": "Yes, drafts autosave every minute. Let me restore it."
              }
            }
          ]
        }
      }'
    ```

    Refine returns `202 Accepted` with a `job_id`; poll `GET /v1/signal-jobs/{job_id}` exactly like
    a create build.
  </Tab>

  <Tab title="Dashboard">
    Open the signal, select **Refine**, upload labeled examples that demonstrate the miss or false
    positive (`refinement-events.json`), review the suggested guidance, and start the rebuild.

    <Frame>
      <img src="https://mintcdn.com/dawn-a6c57108/Wnn0zDxCgBmUm_pg/images/signals-api/refine-signal.jpg?fit=max&auto=format&n=Wnn0zDxCgBmUm_pg&q=85&s=6da2d1177bbdd73b7f35d9219ceecac7" alt="Refine Signal panel with new labeled edge cases and updated guidance" width="1164" height="655" data-path="images/signals-api/refine-signal.jpg" />
    </Frame>
  </Tab>
</Tabs>

Each refinement creates an immutable version. The current active version continues serving until
the new version activates; a failed refinement does not replace it. `base_version` is optional but
recommended: a stale value returns `409` instead of overwriting a concurrent refinement.

<Warning>
  The preview dashboard opens detail controls only for an active API-built signal. If a build
  returns `requires_action`, keep the `signal_id` and `job_id` from the create response, inspect
  its metrics through the API or CLI, and submit a refinement there with more labeled examples.
</Warning>

## CLI reference

The CLI requires Node.js 20 or newer — [install it](#set-up-your-surface) with the one-command
installer. On interactive terminals it renders rich output; pipe it or pass `--plain` for plain
text, or `--json` for machine-readable output. Input files are JSON: `{ "events": [...] }` objects
or bare arrays everywhere, and `evaluate` also accepts a single-event `{ "data": ... }` file. CSV
and spreadsheet mapping is available in the dashboard, not the CLI.

Every `--file` flag also accepts an `s3://<bucket>/<key>` URI. The object is downloaded on your
machine with your own AWS credentials (the standard AWS SDK chain: `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY` env vars, `AWS_PROFILE` shared config and SSO profiles, or instance roles)
and then sent through the same API call as a local file — Raindrop never sees your bucket or
credentials. Any configured `AWS_REGION` works: bucket region redirects are followed. S3-compatible
endpoints (MinIO, LocalStack) are supported via `AWS_ENDPOINT_URL_S3`.

```bash theme={null}
raindrop-signals batch \
  --file s3://my-exports/signals/events.json --watch
```

The CLI reads its saved config automatically. Environment variables and flags override it:

```bash theme={null}
export RAINDROP_API_KEY="YOUR_API_KEY"
export RAINDROP_PROJECT_ID="default" # optional; defaults to default
export RAINDROP_API_URL="https://query.raindrop.ai" # optional

raindrop-signals --help
```

| Command                          | Required input                                                  | Key options                                                                                                                                                                                                                |
| -------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `login`                          | None (prompts for the key)                                      | `--api-key`, `--api-url`, `--project`; validates then saves `0600` config                                                                                                                                                  |
| `logout`                         | None                                                            | Deletes the saved config                                                                                                                                                                                                   |
| `config show`                    | None                                                            | `--json`; effective values (key masked) and each value's source                                                                                                                                                            |
| `create`                         | `--name`, `--description`, and one of `--file` / `--dataset-id` | `--dataset-name`, `--watch`                                                                                                                                                                                                |
| `refine <signal-id>`             | At least one of `--description`, `--file`, or `--dataset-id`    | `--base-version`, `--watch`                                                                                                                                                                                                |
| `list`                           | None                                                            | API-created signals with status and active version                                                                                                                                                                         |
| `get <signal-id>`                | Signal ID                                                       | Definition, versions, mapping, and metrics                                                                                                                                                                                 |
| `job <job-id>`                   | Job ID                                                          | `--watch`; exit 2 on watched `failed` / `requires_action`                                                                                                                                                                  |
| `evaluate <signal-id>`           | `--file` with one `{ "data": ... }` event or a multi-event file | Batches of 100 per request; nothing is stored; multi-event runs write a local HTML report (`--no-html` to skip)                                                                                                            |
| `batch`                          | `--file` containing an event array                              | Fire-and-forget 202s, 1,000 events per request, derived `Idempotency-Key`; `--signal-ids`; `--watch` also writes a local HTML report with event contents from your file (`--html <file>` to place it, `--no-html` to skip) |
| `batch-receipt <receipt-id>`     | Receipt ID                                                      | `--watch` shows per-event results; completed receipts write a local HTML report (`--file` adds event contents, `--no-html` skips); exit 2 on `failed` / `expired`                                                          |
| `ui`                             | None                                                            | `--port`, `--no-open`; serves the Local UI with the key kept local                                                                                                                                                         |
| `dataset create`                 | `--name`                                                        | `--description`                                                                                                                                                                                                            |
| `dataset upload <dataset-id>`    | `--file`                                                        | Auto-chunks large files; waits for each chunk                                                                                                                                                                              |
| `dataset readiness <dataset-id>` | Dataset ID                                                      | Returns label and upload counts                                                                                                                                                                                            |

Every command accepts `--api-url`, `--api-key`, `--project`, `--json`, and `--plain`. Create,
refine, job, batch, and batch-receipt accept `--watch`. Progress goes to stderr and `--json`
results go to stdout, so pipes remain clean.

Exit codes: `0` is success, `1` is a usage or API error, and `2` means a watched outcome went bad
— a build job ended `failed` or `requires_action`, a tracking receipt ended `failed` / `expired`,
or the watch deadline expired. `batch-receipt` also exits `2` for a `failed` / `expired` receipt
without `--watch`.

The Local UI (`ui`) mirrors the CLI's resilience: transient `5xx` and `429` responses are retried
with capped backoff (honoring `Retry-After`), and every event row carries its own live status —
pending, its match / no-match outcome, or an error badge with the HTTP status — so a blip on one
row never sinks the batch.

## Event and trace formats

### Dataset event

```json theme={null}
{
  "event_id": "optional-stable-id",
  "timestamp": "2026-07-17T09:00:00Z",
  "user_id": "optional-opaque-user-id",
  "label": "match",
  "properties": { "account_tier": "enterprise", "channel": "support_chat" },
  "data": { "input": "...", "output": "..." },
  "trace": []
}
```

* `data` is a required JSON object up to 64 KiB for dataset, evaluate, and ZDR events. Any keys and
  nesting are accepted; Raindrop infers the field mapping.
* `properties` are optional explicit dimensions — the same shape and meaning as ZDR event
  properties and regular ingest properties. On dataset events they are retained; classifiers see
  them as `event.data.properties`.
* `label` is `match`, `no_match`, or omitted for an unlabeled dataset member.
* `event_id` is optional for datasets. When omitted, Raindrop derives a deterministic content hash,
  making retries and re-uploads predictable.
* `timestamp` must be an ISO 8601 datetime and may carry a timezone offset (for example
  `2026-07-17T09:00:00+02:00`); it is normalized to UTC. `user_id` can be at most 256 characters.

### CSV or spreadsheet

The dashboard recognizes common input, output, label, ID, timestamp, user, and trace column names. A
minimal CSV looks like this:

```csv theme={null}
event_id,label,input,output
angry-1,match,"I want to speak to a manager RIGHT NOW or I'm posting about this everywhere.","I understand your frustration — connecting you to a supervisor."
calm-1,no_match,"Could you explain the difference between the Pro and Team plans?","Sure — Pro is for individuals, Team adds shared workspaces."
```

Use JSON when your events contain nested objects or traces.

### Rich interaction trace

Attach the ordered interaction when the decision depends on retrieval or tool behavior rather than
text alone. The trace is a flat ordered list; parent/child relations use `span_id` /
`parent_span_id` links, the same way spans relate in standard trace exports:

```json theme={null}
{
  "trace": [
    { "type": "user_message", "content": "Refund my last purchase now.", "span_id": "msg-1" },
    {
      "type": "span",
      "name": "handle_refund_request",
      "span_type": "agent_step",
      "status": "ok",
      "span_id": "step-1",
      "parent_span_id": "msg-1"
    },
    {
      "type": "tool_call",
      "name": "lookup_order",
      "status": "completed",
      "input": { "user": "customer-207" },
      "output": { "order_id": "5521" },
      "span_id": "tool-1",
      "parent_span_id": "step-1"
    },
    {
      "type": "tool_call",
      "name": "issue_refund",
      "status": "completed",
      "input": { "order_id": "5521", "amount_usd": 120.0 },
      "output": { "ok": true },
      "span_id": "tool-2",
      "parent_span_id": "step-1"
    },
    { "type": "assistant_text", "content": "Refund issued.", "parent_span_id": "step-1" }
  ]
}
```

| Type             | Required fields | Optional fields                                                           |
| ---------------- | --------------- | ------------------------------------------------------------------------- |
| `user_message`   | `content`       | `span_id`, `parent_span_id`                                               |
| `assistant_text` | `content`       | `span_id`, `parent_span_id`                                               |
| `tool_call`      | `name`          | `status`, `input`, `output`, `error`, `duration_ms`, span link fields     |
| `span`           | `name`          | `span_type`, `status`, `input`, `output`, `duration_ms`, span link fields |

Traces accept at most 500 entries and 64 KiB of JSON. Tool status is `pending`, `completed`,
`error`, or `unknown`; span status is `ok`, `error`, or `unknown`.

## Dashboard surface map

| Place        | What the preview adds                                                                                                                                    |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Signals**  | Project signal catalog including API-built signals, **New Signal** file flow, build state, active details, **Configure**, **Playground**, and **Refine** |
| **Datasets** | Training-event upload, automatic field mapping, row preview, `match` / `no_match` labels, readiness, and dataset-agent signal proposals                  |
| **Events**   | Existing events plus ZDR event shells, batch upload, per-event receipt, sanitized columns, and a property-first detail view                              |

When preview mode is enabled, the dashboard focuses its primary navigation on **Events**,
**Datasets**, and **Signals**. Existing non-preview behavior remains unchanged otherwise.

## API reference

| Method | Path                                          | Success | Purpose                                                 |
| ------ | --------------------------------------------- | ------- | ------------------------------------------------------- |
| `POST` | `/v1/datasets`                                | `201`   | Create a retained training dataset                      |
| `POST` | `/v1/datasets/{datasetId}/uploads`            | `202`   | Queue an asynchronous upload                            |
| `GET`  | `/v1/datasets/{datasetId}/uploads`            | `200`   | List dataset upload records, newest first               |
| `GET`  | `/v1/datasets/{datasetId}/uploads/{uploadId}` | `200`   | Poll one upload                                         |
| `GET`  | `/v1/datasets/{datasetId}/readiness`          | `200`   | Read label counts and build readiness                   |
| `GET`  | `/v1/datasets/{datasetId}/events`             | `200`   | List retained signal-training events                    |
| `POST` | `/v1/datasets/{datasetId}/event-labels`       | `200`   | Set match/no-match labels on retained events            |
| `POST` | `/v1/signals`                                 | `202`   | Create a signal from an existing or inline dataset      |
| `GET`  | `/v1/signal-definitions`                      | `200`   | List API-created signals with build state               |
| `GET`  | `/v1/signals/{signalId}/definition`           | `200`   | Read status, mapping, versions, classifier, and metrics |
| `POST` | `/v1/signals/{signalId}/refinements`          | `202`   | Build a new immutable version                           |
| `POST` | `/v1/signals/{signalId}/builds`               | `202`   | Build a labeled `requires_action` signal draft          |
| `POST` | `/v1/signals/{signalId}/evaluate`             | `200`   | Evaluate one payload or up to 100, no persistence       |
| `GET`  | `/v1/signal-jobs/{jobId}`                     | `200`   | Poll create/refine build status                         |
| `POST` | `/v1/events/batch`                            | `202`   | Accept a ZDR batch for async classification             |
| `GET`  | `/v1/events/batch/receipts/{receiptId}`       | `200`   | Poll a ZDR receipt for per-event results                |
| `GET`  | `/v1/cli`                                     | `200`   | Download the single-file CLI                            |

The existing `GET /v1/signals` endpoint still lists the whole project signal catalog (all signal
types). Use the top-level `GET /v1/signal-definitions` for API-created signals with build state, and
`GET /v1/signals/{signalId}/definition` for one signal's field mapping, versions, classifier source,
and metrics.

During the private preview, the request and response schemas for the endpoints above are documented
inline in this guide; the public OpenAPI reference at
[`https://query.raindrop.ai/v1/docs`](https://query.raindrop.ai/v1/docs) documents only the
generally available Query API.

Standard Query API limits apply: 200 requests per minute per endpoint, 20 requests per second across
the API key, and 50 requests per second per IP. Synchronous `evaluate` is intentionally tighter:
10 requests per minute and 2 requests per second per organization across all signal IDs. Its `429`
message directs bulk callers to `POST /v1/events/batch`. A `429` response means the caller should
back off and retry. Transient `5xx` responses and network errors — including intermittent `503`s on receipt
polls, and the `SERVICE_UNAVAILABLE`-enveloped `503` that evaluate returns past its internal
25-second deadline — are likewise retryable; retry them with bounded backoff. The one exception is
a *sustained* `503` on `POST /v1/events/batch`, which signals staging backpressure (the batch
was not accepted): resend the batch once staging recovers instead of tight-looping.

Errors use an `{ "error": { "code": "...", "message": "..." } }` body. Common statuses are `401`
for an invalid or missing key, `403` for project access, `404` when preview access or a
resource is unavailable, `409` for a stale version or conflicting build, and `429` for rate limits.

### Important limits

| Resource                          | Limit                                                                      |
| --------------------------------- | -------------------------------------------------------------------------- |
| Dataset or inline upload          | 1–5,000 events and at most 4 MiB of JSON                                   |
| Dataset/evaluate/ZDR event `data` | At most 64 KiB of JSON                                                     |
| ZDR event `properties`            | At most 64 KiB of JSON, retained on every tracked event                    |
| Trace                             | At most 500 entries and 64 KiB; relations via span links                   |
| ZDR request                       | 1–1,000 events (≤ 16 MiB of JSON) and 1–20 explicit signal IDs             |
| Evaluate request                  | One `data` payload or 1–100 `events` per call                              |
| ZDR staging                       | In memory only; 15-minute cap, then unclassified batches finalize `failed` |
| `Idempotency-Key`                 | At most 256 characters; receipt reused for 24 hours                        |
| Signal name / description         | 1–120 / 1–4,000 characters                                                 |
| Dataset name / description        | 1–100 / up to 500 characters                                               |

## Build states and troubleshooting

Signal states are `building`, `active`, `requires_action`, and `failed`. Job states are `queued`,
`running`, `succeeded`, `requires_action`, and `failed`.

<AccordionGroup>
  <Accordion title="The API returns 404">
    Confirm the preview is enabled for the organization that owns the API key. Feature-disabled
    requests intentionally return `404`.
  </Accordion>

  <Accordion title="The build returns requires_action">
    Check dataset readiness and validation metrics. Add at least one `no_match` example, then add
    hard negatives for false positives and positive edge cases for false negatives. The preview
    dashboard does not open non-active signal details, so use the API or CLI with the original
    `signal_id` to submit the refinement.
  </Accordion>

  <Accordion title="A refinement returns 409">
    Another build may be running, or `base_version` may be stale. Read the signal definition, wait
    for the current job to finish, and retry from the latest version.
  </Accordion>

  <Accordion title="A production event was not stored">
    Every tracked event should store a sanitized shell, matched or not. A shell write failure makes
    the receipt `failed`; resend the batch with a fresh `Idempotency-Key` (reusing the same key just
    returns the existing receipt; shell storage is idempotent per event, so already-written shells
    dedupe).
  </Accordion>

  <Accordion title="The stored event has no prompt, answer, or trace">
    That is the ZDR contract. Review the retained event ID, timestamp, opaque user ID, explicit
    properties, and signal membership. Debug the full payload in your own controlled system using
    the event ID as the join key.
  </Accordion>

  <Accordion title="Cleaning up tutorial signals">
    There is no self-serve delete during the preview. A leftover tutorial signal only affects
    ZDR batches that run against all active signals — pin `signal_ids` in production calls and it
    is inert. Ask [support@raindrop.ai](mailto:support@raindrop.ai) to remove signals or retained
    datasets you no longer want.
  </Accordion>
</AccordionGroup>

## Production checklist

* Use synthetic or explicitly approved data for the retained training dataset.
* Keep raw conversation content, prompts, answers, and tool results in `data` or `trace`.
* Put only safe operational dimensions in persisted `properties`.
* Use stable opaque event and user IDs that you can resolve in your own system.
* Send an `Idempotency-Key` for retryable HTTP ingestion.
* Pin `signal_ids` when a service should run only a known set of classifiers.
* Treat `indeterminate` separately from `no_match` in operational metrics.
* Add matched misses and nearby hard negatives together when refining.
* Use `base_version` to protect concurrent refinements.
