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

# Scenario callback

> Fill in scenarios from your own data each time a trace is added to a dataset.

A scenario holds the situation a run was in: the trigger that started it and the data available when it arrived. Bitfab can read the trigger from a trace, but the records your agent could see (messages, contacts, memories, calendar events) live in your systems. The scenario callback lets your code send them.

Each time a trace is added to a dataset, Bitfab sends the trace to one URL you choose. Your code looks up what it holds for that run and answers with it, and Bitfab saves the answer onto the trace's scenario.

## Setup

1. Go to **Integrations** in the Bitfab portal.
2. Under **Scenarios**, click **Connect** on the **Scenario callback** card.
3. Enter the **URL** Bitfab should send to (https only) and a **Signing secret** of at least 16 characters.
4. Click **Save**, then **Test Connection**. Bitfab sends a ping, and any 2xx answer passes.

There is one callback per organization. Every request names its trace function, so one endpoint can handle several functions.

## The request

Bitfab sends a `POST` with a JSON body:

```json theme={null}
{
  "type": "scenario.added_to_dataset",
  "version": 1,
  "organizationId": "8b7c...",
  "datasetId": "1f2e...",
  "traceId": "4d3c...",
  "traceFunctionKey": "agent-turn",
  "sessionId": "ticket-1234",
  "environment": "production",
  "callerMetadata": { "suite": "bad-cases" },
  "rootSpan": {
    "id": "9a8b...",
    "input": { "run_result_id": 5678 },
    "output": { "final_text": "..." }
  },
  "scenario": {
    "id": "2c1d...",
    "name": "Ryan asks for tapas research",
    "description": "...",
    "trigger": { "channel": "SMS", "content": "...", "...": "..." },
    "data": { "topic": "...", "history": [], "missing": [], "records": [] }
  }
}
```

`scenario.trigger` and `scenario.data` are `null` when the scenario has not been generated yet. `rootSpan` is `null` when the trace has no root span. `environment` is the environment the trace was recorded in, or `null`. `callerMetadata` is the metadata your code attached to the trace, `{}` when there is none.

### When Bitfab does not send

Bitfab sends once per trace added to a dataset, and skips the trace when:

* the trace is a replay. Its scenario belongs on the trace it replays;
* Bitfab builds the trigger for this trace function and it came out empty. Nothing is saved, so the scenario keeps showing the trace's input and output.

Adding a trace to a second dataset sends it again, and the answer is saved the same way as the first.

The test ping has a smaller body: `{ "type": "ping", "version": 1, "organizationId": "..." }`.

Bitfab may add fields to the request, and new `type` values, without raising `version`, so ignore the ones you do not know. `version` goes up only when a field you already read changes shape or meaning.

### Verifying the signature

Every request carries a `Bitfab-Signature` header:

```
Bitfab-Signature: t=1760000000,v1=5f2b...
```

`v1` is the hex HMAC-SHA256 of `<t>.<raw body>`, keyed with your signing secret. Compute it over the raw body before parsing it, compare it in constant time, and reject requests whose `t` is more than a few minutes old.

```typescript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto"

function isFromBitfab(rawBody: string, header: string, secret: string) {
  const { t, v1 } = Object.fromEntries(
    header.split(",").map((part) => part.split("=")),
  )
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex")
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
  return (
    fresh &&
    expected.length === v1.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
  )
}
```

## The answer

Answer `200` with a JSON object, or `204` when you have nothing to add. Every field is optional, and a field you leave out is left as it is. A field Bitfab does not know is refused, so a misspelled field fails loudly instead of being dropped.

| Field | What Bitfab does with it |
| - | - |
| `mode` | `merge` (the default) or `replace`. Decides how `records`, `history`, and `missing` combine with what is saved. See below. |
| `records` | Merge: each record replaces the saved records with the same `kind` and `ref`, and other saved records stay. Replace: the records sent become the scenario's records. Either way, each is matched to the span of the run it appears in. |
| `history` | Merge: appended, skipping entries whose `kind` and `quote` are already saved. Replace: the entries sent become the history. |
| `missing` | Merge: appended, skipping text already saved. Replace: the items sent become the missing items. |
| `sources` | Replaces the saved sources. |
| `trigger` | Replaces the trigger. Records are refused while the trigger is empty. |
| `name`, `description` | Replace the scenario's name and description. |
| `actualOutcome` | Replaces what the run actually did. |

A record looks like this:

```json theme={null}
{
  "kind": "memory",
  "ref": "Memory:812",
  "at": "2026-09-27T12:00:00Z",
  "asOf": "run",
  "title": "Seat preference",
  "text": "Ryan prefers aisle seats on long flights.",
  "quote": null
}
```

`kind` is any name for the kind of record your systems hold, such as `memory`, `order`, or `ticket`. `ref` names the record in your systems. `at` is when it was written, or `null`. `asOf` is `run` when the record is as it was when the run started, and `now` when it is as it is today. `quote` is the exact text Bitfab looks for in the run's spans, falling back to `text` when it is `null`.

### The trigger

`scenario.trigger` is `null` until a trigger is saved, and records are refused until one is. Send it in the same answer as the records. History, missing items, and the actual outcome can be saved without a trigger, and the trigger stays unset until an answer sends one. Every field is required:

| Field | What it holds |
| - | - |
| `content` | What started the run, in full. A trigger whose content is blank counts as empty |
| `contentPassages` | `content` split into the passages a person would read separately, or `[content]` |
| `contentIsWholeMessage` | `true` when `content` is the whole message, `false` when it is an excerpt |
| `type` | A few words on what was asked, such as `refund request` |
| `from` | Who or what sent it |
| `to`, `cc`, `subject` | The recipient, copied recipients, and subject, or `null` |
| `channel` | Where it arrived, such as `email`, `chat`, or `api` |
| `inputType` | What kind of input it was, such as `message` or `webhook` |
| `inputId` | A numeric id for the input in your systems, or `null` |
| `eventTime` | When it arrived, ISO 8601 |
| `eventTimeSource` | `message date` when `eventTime` is the message's own date, `run start` when it is when the run started |
| `source` | `request` when the content came from the request, `run instructions` when it came from instructions the run was given |
| `sources` | Where `content` appears in the run, as `{ spanId, sourceField, text }`, or `[]` |

### History and sources

A `history` entry is one earlier fact, in time order:

```json theme={null}
{
  "role": "fact",
  "kind": "earlier message",
  "quote": "Can we move the trip to Friday?",
  "summary": null,
  "origin": null,
  "sentAt": "2026-09-26T09:12:00Z",
  "weekday": "Friday",
  "sincePrevious": null,
  "source": null
}
```

`role` is `thread start` for the message that opened the thread and `fact` for anything else.

`sources` describes how a captured copy of your systems was read. Its shape is specific to captures Bitfab already reads, so leave it out unless Bitfab has asked you to send it.

### Merge or replace

Leave `mode` out, or send `merge`, to add to what the scenario holds. Send `replace` when your answer is the full picture: each of `records`, `history`, and `missing` that you send replaces the saved one whole, so an empty list clears it. A list you leave out is kept in either mode, so `{ "mode": "replace", "records": [] }` clears the records and nothing else.

Repeats inside one answer are dropped in both modes: identical records, history entries with the same `kind` and `quote`, and the same missing text.

In merge mode, sending the same answer twice leaves the scenario as it was after the first.

Describe the situation only. A scenario is read by the person who labels the run, so never say what the run should have done.

## Failures

Bitfab waits up to 60 seconds for an answer. A timeout or a non-2xx answer is retried up to three times. An answer that is not JSON, or does not match the shape above, is not retried. The run's error names the field that was refused.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.