File DA-017 View as Markdown

Record your public agent

This page is for a business that runs an MCP server or an A2A agent for its customers, such as a support or ordering agent. You add the recorder @doubleagent-so/observe to your server, and every call to your agent shows in the portal: who called, for whom, what they did and how it ended.

Before you start: an Owner or Admin role on a Double Agent account, and a server written in TypeScript or JavaScript (Node 20+, Cloudflare Workers, Bun or Deno).

1. Add the agent and copy its key

  1. Open app.doubleagent.so/agents and click Add agent.
  2. Give it a name, for example "Orders MCP". The Agent Card URL is optional and only for A2A agents.
  3. Pick the first source: Live for production traffic, Test for local runs and staging. The portal keeps the two apart, and you can add the other one later in Setup.
  4. Leave Capture message content on if you want to read the messages later, or turn it off to keep metadata only. See What is kept.
  5. Click Create agent and key and copy the ingest key. It is shown once. Store it as the secret or environment variable DOUBLEAGENT_AGENT_KEY.

The key can only send events for this agent's source. It cannot read anything. If you lose it, rotate it in Agents › your agent › Setup › Keys.

2. Install the recorder

npm install @doubleagent-so/observe

It has no runtime dependencies. If you use the official SDKs, @a2a-js/sdk 1.3+ and @modelcontextprotocol/sdk 1.29+ work with it.

3. Wrap your server

Pick the one that matches how your agent is built. All of them use one recorder:

import { createRecorder } from '@doubleagent-so/observe';

const recorder = createRecorder({ key: process.env.DOUBLEAGENT_AGENT_KEY!, adapter: 'orders-mcp@1.0.0' });
Your serverUseFrom
MCP server on the MCP TypeScript SDKinstrumentMcpTransport@doubleagent-so/observe/mcp
MCP server you wrote yourself (Streamable HTTP)withMcpTelemetry@doubleagent-so/observe/mcp
A2A agent on @a2a-js/sdkinstrumentA2AHandler and instrumentTaskStore@doubleagent-so/observe/a2a
A2A agent on Workers, Hono or any fetch-style serverwithA2ATelemetry@doubleagent-so/observe/a2a
Any other protocolrecorder.startOperation@doubleagent-so/observe

MCP on the SDK. Wrap the transport before you connect it:

import { randomUUID } from 'node:crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { instrumentMcpTransport } from '@doubleagent-so/observe/mcp';

const transport = instrumentMcpTransport(new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }), {
  recorder,
  role: 'server',
  binding: 'streamable-http',
  issuer: 'https://auth.example.com',
});
await server.connect(transport);

A2A on @a2a-js/sdk. Wrap the request handler and the task store, so task changes made in the background are recorded too:

import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
import { instrumentA2AHandler, instrumentTaskStore } from '@doubleagent-so/observe/a2a';

const taskStore = instrumentTaskStore(new InMemoryTaskStore(), { recorder });
const handler = instrumentA2AHandler(new DefaultRequestHandler(card, taskStore, executor), { recorder, issuer: 'my-idp' });

A2A on Cloudflare Workers. Build the recorder inside fetch, where the secret is available, and set waitUntil:

import { createRecorder } from '@doubleagent-so/observe';
import { withA2ATelemetry } from '@doubleagent-so/observe/a2a';

// app is your Hono app (or any fetch handler) that serves the A2A routes.
let handle;

export default {
  fetch(request, env, ctx) {
    handle ??= withA2ATelemetry(app.fetch, {
      recorder: createRecorder({ key: env.DOUBLEAGENT_AGENT_KEY, adapter: 'orders-a2a@1.0.0', flushIntervalMs: 0 }),
      waitUntil: true,
    });
    return handle(request, env, ctx);
  },
};

Any other protocol. Start an operation, record its messages, then finish it:

const op = recorder.startOperation({
  protocol: { name: 'custom:quote-api', version: '1.0', binding: 'http-json' },
  direction: 'inbound',
  method: 'quotes.create',
  kind: 'tool',
  target: 'flight-quote',
});
op.message({ role: 'caller', parts: [{ kind: 'data', json: request }] });
op.finish({ outcome: 'ok' });

The recorder never throws into your code and never changes your requests or responses. Your handler's own errors are recorded and rethrown unchanged.

On Workers and serverless, flush every request

The recorder sends events in batches every 2 seconds. A serverless runtime can stop that work as soon as the response goes out, and the events are lost. So:

  • Set flushIntervalMs: 0 on the recorder.
  • withA2ATelemetry and withMcpTelemetry with waitUntil: true flush for you.
  • With instrumentMcpTransport, the A2A SDK wrappers or startOperation, call ctx.waitUntil(recorder.flush()) at

the end of each request.

On a long-running server, call await recorder.shutdown() before the process exits.

4. Send a test event

Deploy, then open Agents › your agent › Setup › Install and click Send test event. It checks the key, ingest, storage and the dashboard, and shows each step as it passes: Received, Stored, Visible. The test shows in Operations as a call from "Double Agent test". You can send 10 test events an hour per source.

Then call your agent once yourself and open Operations. If nothing shows, check that the Live / Test switch at the top of the screen matches the source you created.

5. Tell Double Agent who called

Without any help, a caller is declared at best: the MCP clientInfo or the name it gave, which proves nothing. Your server already knows more. Pass what your own auth checked, and the caller moves up the verification levels.

With @doubleagent-so/observe 0.2.1 you can pass:

  • The signed-in customer. Return { authenticated: { issuer, subject } } from identify(request) on

withMcpTelemetry or withA2ATelemetry. With the SDK wrappers, the MCP SDK's authInfo and the A2A SDK's context.user are read for you. The caller becomes authenticated.

  • A signature your server checked. Add signature: { scheme: 'web-bot-auth', key_id, verified_by: 'reporter' }.

The caller becomes verified, reported by your server.

export default {
  fetch: withMcpTelemetry(handleMcp, {
    recorder,
    waitUntil: true,
    // verifyAccessToken is your own: it checks the token and returns the customer id.
    identify: async (request) => ({ authenticated: { issuer: 'https://auth.example.com', subject: await verifyAccessToken(request) } }),
  }),
};

The subject is hashed with your key before it leaves your server. Tokens and Authorization headers are never sent.

Grants, scopes and forwarded signatures (next observe release)

These fields are in the recorder's source but not in 0.2.1. They need the next @doubleagent-so/observe release:

WhatHowGives you
OAuth claims, including the acting agent (act)oauthEvidence(claims) from identifyClient id, scopes, and delegated when an agent acts for the customer
A Web Bot Auth or ERC-8128 signature for Double Agent to check itselfsignedRequestEvidence(request)verified, checked by Double Agent
A PACT, A2A or AP2 grantdelegation on the counterpartydelegated, with scopes, read or write, and expiry
A refusal for missing scopesparseInsufficientScope(wwwAuthenticate); the fetch wrappers do it for every 403A scope violation
Read, write or destructiveaccess and scopeRequired on startOperation; the MCP adapter reads tool annotationsThe read / write split in Callers

The fields and what each level means are on Callers.

What shows where

All of these are under Agents › your agent:

ScreenAnswers
OverviewHow many conversations and calls, how many tasks finished, error rate and latency, against the previous period
ConversationsOne conversation as a timeline: each call, task state, message and payment, with timings
TasksWhich tasks are open, waiting for input, stalled or done. Set the stall threshold (10 minutes to 4 hours) in Setup › Sources
CounterpartiesWho calls your agent and who it calls, and how strongly each one is identified
CallersEach caller and the customer it acts for, with its level, grant and scope violations. See Callers
ThreatsCallers that probe tools, scrape resources, try prompt injection or flood. See Intent
RevenueWhat callers paid and what serving them cost, by counterparty, tool and task
OperationsEvery call, filtered by outcome, direction or kind

Revenue shows only to Owners, Admins and roles allowed to see revenue. To record money, see Revenue and cost in the recorder's README. x402 payments are recorded without any code.

Get alerts when a caller goes outside its grant

In Agents › your agent › Setup › Webhook, set a URL and a signing secret. Double Agent sends one caller.scope_violation event per grant and day when a caller acts outside the scopes it was granted. Verify each delivery as in Debrief. The payload is on Callers.

What is kept

  • Metadata, always: who called, the method and target, the outcome, timings and task states.
  • Message content, only with capture on. The recorder sends message text and data by default. Double Agent keeps it

for 30 days only for sources with Capture message content on, and drops it otherwise. Only Owners and Admins can read it, and every read is logged. Turn capture off for a source in Setup › Sources, and only Owners can delete what was captured. To never send content at all, use createRecorder({ key, content: false }).

  • Never sent: file bytes and file URLs, access tokens, Authorization, Cookie and Proxy-Authorization headers.
  • Not seen: your agent's internal tool calls, model calls and reasoning. Only what crosses the protocol boundary is

recorded.

Operations are kept for 30 days. The recorder sends at most 1,000 requests a minute per source, in batches of up to 100 events.

Next

  • Callers: read the Callers screen and send grants.
  • Intent: what puts a caller in Threats.
  • The full recorder API, outbound calls and limits: the observe README.