# Telltale for APIs

Some agents skip your pages and your MCP server and call your API directly. Telltale for APIs records those calls, per account and per route, beside the agents in your pages and the agents using your MCP server: one record of AI agent activity across every account and every channel.

It's one middleware in the server you already run: Node's `http` server, Express, Next.js route handlers, Hono or Fastify. Your routes, handlers and responses stay as they are. Telltale never receives a request's content.

## What you get

- **Per account and per route:** calls, errors (by status class), refusals and how long each call took, for routes such as `POST /v1/invoices/:id`.
- **Which agents, and how we know:** each call that carries agent evidence is labelled with the agent and how we know (Verified by token, Verified, Published IP range or Declared). A call with no agent evidence is **Unknown**. This channel has no human signals, so a call is never labelled Human, and Unknown never means a person.
- **One record per account:** agent calls are approved-route calls with channel `api`, so they land in the [agent-usage ledger](/docs/usage-ledger), the account drawer, Accounts › All accounts, Home and Charge › Usage, split by channel (browser, MCP server, API).
- **Issues:** agent calls that failed (server errors, refused input, not allowed) are counted in Issues and in agent goals.
- **Rules for protected routes:** a route you name asks for your rule (Control › Rules) before your handler runs: allow, hold for review (with your own step-up) or deny.

## Install

Node 18+ or any runtime with `fetch` and Web Crypto. The middleware has no dependencies.

1. Download the adapter, check its SHA-256 against the [adapters table](/docs/server-adapters), and put it beside your server code. It is an ES module: in a project whose package.json says `"type": "commonjs"` (what `npm init` writes), save the two files as `telltale-api.mjs` and `telltale-api.d.mts` and import `./telltale-api.mjs`:

   ```sh
   curl -sSo telltale-api.js https://app.trytelltale.dev/adapters/v1/api.js
   curl -sSo telltale-api.d.ts https://app.trytelltale.dev/adapters/v1/api.d.ts
   ```

2. Create the client **once per process**, with the Site's **server key** (Settings › Channels › Server keys). The key stays on your server:

   ```javascript
   import { createTelltaleApi } from './telltale-api.js';

   export const telltale = createTelltaleApi({
     endpoint: 'https://app.trytelltale.dev',
     serverKey: process.env.TELLTALE_SERVER_KEY,
     identify: (req) => ({ accountId: req.user?.accountId, userId: req.user?.id }),   // your own opaque IDs
   });

   process.on('SIGTERM', async () => { await telltale.close(); process.exit(0); });
   ```

3. Add it to your framework (below), then make one request to your API. **Settings › Channels › API › Is it working?** turns to "Working" with the time of the last batch, usually within five seconds.

### Node http

```javascript
import http from 'node:http';
http.createServer(telltale.node((req, res) => { /* your handler */ })).listen(3000);
```

The Node server has no route templates of its own, so the built-in normaliser (below) or your `route` function names the route.

### Express (4 and 5)

```javascript
app.use(telltale.express());                                          // early: records every request
app.post('/v1/invoices', requireAuth, telltale.protect(), createInvoice);   // a protected route, after your auth
```

The route template is read from Express (`req.baseUrl` + `req.route.path`, for example `/v1/invoices/:id`) when the response finishes.

### Next.js route handlers (App Router)

```javascript
// app/api/invoices/[id]/route.js
import { telltale } from '@/lib/telltale';
export const GET = telltale.next(async (request, context) => Response.json(await loadInvoice(context.params)), { route: '/api/invoices/[id]' });
```

Pass the file's route as `route`; a route handler doesn't know its own template. On Vercel, set `trustProxy: 1` so the client address comes from `X-Forwarded-For`. In a serverless function, call `await telltale.flush()` before it returns, or hand it to the platform (`waitUntil(telltale.flush())`).

### Hono

```javascript
app.use(telltale.hono());
app.post('/v1/invoices', requireAuth, telltale.honoProtect(), createInvoice);
```

On Cloudflare Workers, flush with `c.executionCtx.waitUntil(telltale.flush())`.

### Fastify (4 and 5)

```javascript
await fastify.register(telltale.fastify);
```

The plugin applies to the whole instance. The route template is Fastify's (`request.routeOptions.url`). Protected routes are checked in a `preHandler` hook.

## Route templates

Telltale stores the route template, never the path a request used and never a query string.

1. Your own `route: req => '/v1/invoices/:id'` function wins, when you give one.
2. Otherwise the framework's route (Express, Fastify, Hono, or `route` for Next.js), cleaned to the characters Telltale accepts (regular-expression groups such as `:id(\\d+)` are removed).
3. Otherwise the built-in normaliser: the query and fragment are dropped, and a segment is kept only when it reads like a word (a letter first, at most two digits, not a hex run, not a prefixed ID). Numbers, UUIDs, ULIDs, hashes, prefixed IDs such as `inv_0` or `cus_NffrFeUfNV2Hib`, email addresses, slugs with digits and encoded text become `:id`. So `/v1/invoices/12345?secret=x` and `/v1/invoices/inv_0` are both recorded as `/v1/invoices/:id`. At most 16 segments.

   Changed in @telltale/api 1.0.2: prefixed IDs (a short word, an underscore, then a value with a digit or mixed case) and UUIDs written with few digits now become `:id`; 1.0.1 kept them, so a route such as `/v1/invoices/inv_0` was recorded once per ID. Every other route is recorded exactly as before. Routes already recorded keep their old name; new calls from 1.0.2 are recorded under the template.

**On Node's own `http` server, pass `route`.** Plain `node:http` has no router, so without `route` every path goes through the normaliser, and a short ID that reads like a word stays as it is: `/v1/reports/r1` is recorded as `/v1/reports/r1`, not `/v1/reports/:id`. Such a route then never matches a rule you saved for `GET /v1/reports/:id` (the call is not checked) and each ID shows as its own route. Give `telltale.node(handler)` a `route` function (`route: req => matchMyRoutes(req.url)`) that returns your template. The dashboard warns in Tools › All tools when a Node route looks raw.

Each process keeps at most `maxRoutes` distinct templates (default 300); a new one after that is recorded as `/:other`. The server refuses a route that still looks like it carries a value (three or more digits in a row, a UUID, a long hex run, a long token with digits) and reports it in Is it working?.

## Identity

`identify(req)` returns `{ accountId, userId }` from your own authentication. Send opaque IDs only, never an email address or a name.

- It runs after your handler (so your auth middleware has set the user), or before it for a protected route.
- A call is chargeable only when your server named its account (or an agent token did) and the rest of the chargeable rule holds (below). A call without an account is counted, never chargeable.
- **Identified users are counted once across channels.** A person your server identified in the browser, through your MCP server and through your API is one identified user that month on Telltale's plans.

## Privacy: what is and isn't sent

Sent to `POST /api/v1/api-calls` (server key) in batches. The batch:

| Field | What it is |
| --- | --- |
| `batchId` | random, 8 to 128 letters, digits, `_` or `-`; kept across retries so a resent batch is counted once |
| `sdk` | `{name, version}` of the middleware, printable ASCII, at most 64 characters each |
| `framework` | `node`, `express`, `next`, `hono`, `fastify` or `other` |
| `dropped` | whole number of calls the middleware dropped because its queue was full |
| `calls` | 1 to 200 calls |

Each call:

| Field | What it is |
| --- | --- |
| `eventId` | random per call, 8 to 128 letters, digits, `_` or `-` |
| `method` | `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` or `OPTIONS` |
| `route` | the route template: a leading `/`, then at most 255 letters, digits or `_ . ~ : { } [ ] * + / -`; no query, fragment, `%`, `=`, `@` or spaces |
| `status` | the HTTP status code you answered (100 to 599) |
| `startedAt`, `endedAt` | milliseconds; within the last 7 days, at most a day long |
| `size` | the response size bucket: `empty`, `under_1kb`, `1kb_10kb`, `10kb_100kb`, `100kb_1mb`, `over_1mb` or `unknown` |
| `responseBytes`, `responseTokens` | middleware 1.1.0 and later, when the size is known (`Content-Length` or the bytes written): the response's byte count and an estimated token count (bytes / 4, rounded up), counted by the middleware. Used for **Oversized responses** in Issues. Two numbers; the body is never read for this |
| `accountId`, `userId` | what your `identify` returned, at most 512 characters each |
| `sampleRate` | only on a sampled call: the share kept (above 0, at most 1) |
| `decisionId`, `refusedBy` | the rule decision behind a protected call; `deny`, `hold` or `unreachable` when the middleware refused it |
| `evidence` | agent evidence material, used once and never stored (below) |
| `captured` | only with [Deep insights](/docs/deep-insights) on (owner-enabled, off by default) and a call with agent evidence: the body and query fields you listed for that route |

Telltale refuses any other field (per call, reported back as `rejected`; the rest of the batch is kept).

**Never sent** (unless you turn on [Deep insights](/docs/deep-insights) and list body or query fields, and even then never headers or credentials): request or response bodies, query values, the raw path, other headers, cookies, `Authorization` values, your own tokens and session IDs, and the user-agent string. The middleware sends no diagnostics about itself anywhere and never writes to stdout; `debug: true` (or `TELLTALE_DEBUG=1`) writes shapes-only lines to stderr or to your own function ([debug mode](/docs/custom-events#debug-mode)).

### Agent evidence material: used once, never stored

To tell whether an agent made a call, the middleware forwards a little more, inside `evidence`. Telltale uses it while it records the batch and keeps only the result (the agent's name, how we know, the confidence and a network label such as "cloud provider"):

| Field | What it is |
| --- | --- |
| `ip` | the client address (the socket, or `X-Forwarded-For` with `trustProxy`, or your `clientIp` function) |
| `secure` | `true` when the request arrived over HTTPS |
| `signature` | only when the request carries a Web Bot Auth signature (`tag="web-bot-auth"`) that covers nothing but `@authority`, `@method`, `@path`, `@scheme` and `signature-agent`: `{authority, path?, headers: {signature, signature-input, signature-agent}}`. The path is sent only when the signature covers it, and never the query |
| `declared` | `{id, via}`: the ID of a declared agent name the middleware matched in the user agent or a header you listed in `declaredHeaders`, such as `chatgpt-user`. Never the header value itself |
| `agentToken` | the SHA-256 hash of a Telltale agent token the request carried (by default the `Telltale-Agent-Token` header, or `Authorization: Bearer tt_agent_…`). Never the token |

The address, the signature headers and the hash are never stored, logged or returned, as with Telltale's own traffic.

### Data stays with you

To keep the full requests for your own debugging, pass them to your own logs or OpenTelemetry. Telltale gets none of it:

```javascript
createTelltaleApi({ /* … */, dataStaysWithYou: (call) => logger.info({ route: call.route, status: call.status, ms: call.durationMs }) });
```

### Deep insights (opt in, owner-enabled)

Off by default. With `deepInsights: {routes: {'POST /v1/invoices': {body: ['amount'], query: ['expand']}}}` and the site owner's switch on, the middleware sends the fields you list for calls that showed agent evidence; Telltale stores them only for AI agent calls, for 7 to 30 days. Headers, cookies, `Authorization`, tokens and other credentials are never sent or stored, whatever you list. See [Deep insights](/docs/deep-insights).

**Agents known only by their network address are not captured.** "Agent evidence" here means a signal the middleware can see on the request itself: a Web Bot Auth signature, a declared agent name in the user agent or a header, or a Telltale agent token. An agent that Telltale recognises only by its address (an AI provider's published egress range, "Published IP range") is identified on Telltale's side after the call has been sent, so the middleware never had a reason to send that call's fields, and none are captured for it. This is deliberate: the middleware cannot tell such a call from a person's, and people's request bodies stay on your server. The call itself is still recorded, counted and shown as an AI agent call.

## How Telltale knows it was an agent

Strongest first. The first that applies names the call:

| Evidence | Shown as | Confidence |
| --- | --- | --- |
| A valid [agent token](/docs/approved-route#3-agent-tokens-for-your-api) your backend issued | Verified by token | High |
| A valid Web Bot Auth signature from a reviewed operator | Verified | High |
| A declared agent name whose operator publishes an IP list, and the address is in it | Verified (published IP range) | High |
| The address is in an AI agent provider's published egress list (OpenAI, Anthropic, Perplexity, Google) | Published IP range | High |
| A declared agent name we could not check | Declared | Medium |
| None of these | Unknown | |

**Verified (published IP range)** means the call named its agent and its address is in that agent's own published list; **Published IP range** means only the address matched an AI provider's list, with no name to check.

- A declared name whose list was fresh and did **not** contain the address is Unknown, not an agent: someone may be borrowing the name.
- An address in an AI agent provider's published egress list is High confidence on its own: the provider publishes those addresses as its own agents' traffic, and an API call has no browser and no person behind it. (On a web page the same address never makes a session an AI agent; it only stops Telltale from calling it a person.)
- A cloud or data-centre address alone is not agent evidence. It is kept as a network label only.
- Medium confidence still shows as "AI agent", is held instead of denied at a protected route (unless your site blocks medium confidence) and is never chargeable.
- Confidence is a band from what backs the call, never a probability.

## Sampling high-volume APIs

```javascript
createTelltaleApi({ /* … */, sampleRate: 0.1 });
```

Sampling applies only to calls with no agent signal the middleware can see (no signature, no declared name, no agent token) and no rule check. Those calls are kept at the rate you set and carry it, so the route counts in the dashboard are estimates. Every call with a visible signal, and every protected call, is always sent. A sampled call that Telltale later names by its network is recorded but **never chargeable**, because its count is an estimate. Leave out health checks and static files with `skip: req => …`.

## Rules for protected routes

Name the routes that should ask for your rule (Control › Rules) before your handler runs:

```javascript
createTelltaleApi({
  /* … */,
  protect: {
    routes: [
      'POST /v1/invoices',                                                        // a write: fails closed when Telltale can't be reached
      { route: 'GET /v1/reports/:id', whenUnreachable: 'closed' },
      { route: 'DELETE /v1/invoices/:id', actionId: 'delete_invoice' },           // your own rule name
    ],
    // whenUnreachable: 'open' | 'closed' | { read, write }, decideTimeoutMs: 1500
  },
  onHold: async ({ decisionId, method, route, accountId, userId, req }) => {
    const ok = await askThePersonInYourApp(userId, route);   // your own step-up
    return ok ? { result: 'passed', method: 'own_approval' } : 'failed';
  },
});
```

- Control › Rules lists the routes seen through your API, marked "API". Save a rule there; it is kept under the route's action ID (derived from the method and route, or the `actionId` you gave).
- The branch of the call's evidence decides: **AI agent** for any agent evidence, **Unknown** otherwise. There is no Human branch on this channel. A protected route with no saved rule follows the defaults: AI agent **Hold for review**, Unknown **Allow** (until you save a rule that holds or denies it).
- **Medium confidence** (a Declared name) is held for review instead of denied, unless your site blocks medium confidence.
- **Agent tokens:** issue tokens with `apiRoutes: ['GET /v1/reports/:id']` ([Approved route for agents](/docs/approved-route)). A valid token names the account; in scope for the route, it turns a hold into allow. A Deny rule still denies.
- **Deny:** your handler doesn't run. The agent gets `403 {"error": "agent_not_allowed"}`.
- **Hold for review:** your handler doesn't run yet. With `onHold`, your own step-up runs and, if it passes, the call goes ahead. Otherwise the agent gets `403 {"error": "approval_required", "decisionId": …}`; once a teammate approves it in Control › Approvals › Waiting or your step-up passes, the agent's next try for the same route and account runs (once, within 15 minutes). A call with no account is never let through on an earlier approval: nothing ties one anonymous caller to the next (many share an address), so it is held again unless your own step-up passes for that call.
- **Telltale unreachable:** the check waits at most 1.5 seconds. Writes (POST, PUT, PATCH, DELETE) fail **closed** by default (`503 {"error": "check_unavailable"}` with `Retry-After: 60`); reads fail open. Change it per route or for all with `whenUnreachable`.
- Unknown and allowed calls are ordinary traffic and leave no decision record.
- A rule decision is evidence that Telltale's rule allowed the call. It is not authorization: your server still checks what the account and user may do.

### The decide and step-up endpoints

The middleware calls these for you; any language can call them with the server key:

- `POST /api/v1/api-calls/decide` with `{method, route, actionId?, accountId?, userId?, evidence?, approvedDecisionId?}` answers `{decision: allow | review | deny, basis: rule | default_rule | agent_token | approval, verdict: agent | unknown, agent: {name, howWeKnow, confidence} | null, actionId, route, decisionId?, heldInsteadOfDeny?, stepUp?, accountId, userId}`. `verdict` is never `human` on this channel: a server-to-server call carries no evidence of a person, so a call without agent evidence is `unknown`. A hold comes back as `decision: "review"` (shown in the dashboard as Waiting for a decision).
- `GET /api/v1/api-calls/decisions/{id}` answers the state of a held call.
- `POST /api/v1/api-calls/decisions/{id}/step-up` with `{result: passed | failed, method?: passkey | mfa | turnstile | own_approval | other}` settles a hold with your own check; passed approves it for one use within 15 minutes.

## Billing

An API call is chargeable when all of these hold: your server (or an agent token) named the account, the evidence is high confidence, the call ran (not refused) and it was not sampled. Every other agent call is counted and shown, never chargeable. Telltale's own plans count identified users a month, once across channels, never calls ([Pricing](/docs/pricing)).

## Batching and outages

Calls are queued in memory and sent in batches of up to 200 every five seconds. Network errors, 408, 429 and 5xx answers are retried with growing waits (up to a minute, honouring `Retry-After`), with the same `batchId`, so a batch whose answer was lost is never counted twice. Any other refusal drops the batch, logged once with `debug: true`. The queue holds 20,000 calls; past that the oldest are dropped and counted ("dropped" in Is it working?). **A Telltale outage never slows or fails a request.** Only protected routes wait, and only up to their timeout.

Call `close()` on shutdown and `flush()` at the end of a serverless invocation; calls still queued when a process is killed are lost (nothing is kept on disk). The server accepts up to 1,200 batches and 12,000 rule checks a minute per Site; a 429 just makes the middleware wait.

## Where it shows in the dashboard

- **Settings › Channels › API:** framework snippets and **Is it working?** (waiting for the first call, working, or quiet), with the middleware version and framework, the last batch, dropped calls and the last rejected field.
- **Channel split** on Home, the agent report and Charge › Usage: browser, MCP server and API.
- **Control › Rules:** API routes beside your protected actions and MCP tools.
- **Accounts › All accounts:** ranked by agent activity across channels (browser agent minutes plus MCP and API agent calls), so an account whose agents only use your API is listed too.
- **Session drawer:** an agent's API calls grouped into a session (account, user and agent, split after 30 idle minutes), with a timeline of route, outcome and time. No content.
- **Issues:** routes whose agent calls run longer than your slow-call limit (**Slow tool calls**, 30 s by default) or answer more than your token limit (**Oversized responses**, 20,000 tokens by default), beside the errors. Set both limits, for the Site or per route, in Issues › Limits. Neither changes error counts.
- **Tools › All tools:** each route's agent calls, error rate and p50/p95 by agent, plus all calls (agent or not) with "at most" times; filter by agent or by a tag (`tags: req => ({ plan: req.user?.plan })`). Custom events (`telltale.event('invoice.paid', { accountId })`) are counted there too. The bell tells you when a route starts failing for agents and when an account makes its first agent call. See [Custom events, tags and debug mode](/docs/custom-events).

## Troubleshooting

- **"Waiting for the first call" stays on.** Check the server key (a publishable `ao_pk_` key is refused) and that your server can reach `https://app.trytelltale.dev`. Turn on `debug: true` and look at stderr.
- **A route is rejected as "looks like it carries a raw value".** Your framework or `route` function sent a real path such as `/v1/invoices/12345`. Return the template (`/v1/invoices/:id`) or let the built-in normaliser do it.
- **Routes show as `/:other`.** More than `maxRoutes` distinct templates in one process, usually because raw paths are being sent. Fix the template, or raise `maxRoutes`.
- **Every call is Unknown.** Expected for people and for agents that don't identify themselves. Behind a proxy or load balancer, set `trustProxy` (or `clientIp`) so the address is the client's, not the proxy's.
- **Calls but no accounts.** `identify` returned nothing: it runs after your handler, so check what your auth middleware sets on the request.
- **A signed agent isn't Verified.** A signature that covers headers or the query beyond the components listed above is not forwarded. A batch that arrives after the signature expired can't verify either; keep the default five-second interval.
- **Counts look round on a busy route.** Sampling is on: the route's counts are estimates.

See [Approved route for agents](/docs/approved-route) for agent tokens, [Telltale for MCP servers](/docs/mcp) for the MCP channel, and [Agent-usage ledger](/docs/usage-ledger) for how calls are counted.
