Interview ServiceAPI documentation

Interview Service — API documentation

The complete usage reference for the service as it is built today: the client REST API, the MCP server, the webhook callbacks, the respondent endpoints, and the interview config that drives all of them.

This document describes what the code does. The design package under docs/interview-service/ describes what was designed — where the two differ, this file is right and the difference is usually noted inline.


Table of contents

  1. Concepts
  2. Getting started
  3. Authentication
  4. Conventions
  5. The interview config
  6. Interviews API
  7. Campaigns API
  8. Agents API
  9. Live observation (SSE)
  10. Webhooks
  11. Client-defined tools
  12. MCP server
  13. Respondent endpoints
  14. Errors
  15. Limits and lifecycle
  16. Recipes

1. Concepts

Term What it is
Client An API key. Everything you create belongs to it; you can never see another client's data.
Interview One AI-conducted conversation with one person. Created by you, opened by them via a private link.
Campaign One shareable link that mints a separate private interview for every person who opens it.
Agent A stored, named, partial config — a reusable interview archetype. What it sets is fixed for every launch; what it leaves open is a slot the launch call fills.
Config The object that defines an interview: goal, checklist, persona, language, limits, result schema.
Checklist The things the interview must cover. The interviewer tracks each item as pending / covered / no_signal.
Respondent The human being interviewed. They never sign in — possession of the link is the whole credential.
Event log The append-only record of everything that happened. The transcript and the live feed are both projections of it.

The flow, in one paragraph. You create an interview with a config and get back a respondentUrl. You deliver that link yourself — by email, chat, ticket, whatever you have; the service never contacts anyone. The person opens it and talks to the AI interviewer. While that happens you can watch (SSE or transcript polling) and steer (guidance notes). When the goal is reached — or a limit trips, or you close it — the interview ends, the conversation is harvested into your resultSchema, and your callback fires.


2. Getting started

Base URL locally: http://localhost:3005. In production it is whatever APP_URL is set to; respondent links are built from that value.

pnpm install
pnpm --filter @interview-service/app db:migrate
pnpm --filter @interview-service/app db:seed     # prints an API key, once
pnpm --filter @interview-service/app dev

On a deployed container, mint the FIRST key with:

docker exec -it <container> node apps/interview-service/scripts/provision-key.mjs "my-client" --create

Afterwards you rarely need the container: POST /v1/keys/rotate replaces a key over HTTP. The script is for the cases HTTP cannot serve — the first key, and a key that is lost rather than merely being replaced. Its other modes:

provision-key.mjs --list             # see what exists; changes nothing
provision-key.mjs --id <client-id>   # rotate by id — the stable reference
provision-key.mjs <name>             # rotate by name; refuses an unknown name

--create is required to make a new client, and an id-shaped argument is refused rather than treated as a name. Both guards exist because the script used to upsert by name: a typo silently produced a new, empty tenant with a working key and none of your interviews in it (S22).

Create your first interview:

curl -s -X POST http://localhost:3005/v1/interviews \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "config": {
      "goal": "Understand how this person uses the product day to day.",
      "checklist": [
        {"id": "USAGE", "label": "Usage", "description": "What they do with it, how often."},
        {"id": "FRICTION", "label": "Friction", "description": "Where it gets in their way.", "required": true}
      ],
      "language": {"start": "en"}
    }
  }'

The response carries respondentUrl. Open it in a browser and the interview begins.


3. Authentication

Every client endpoint (/v1/* and /api/mcp) takes a bearer API key:

Authorization: Bearer ivk_<32 hex>

Keys are stored only as SHA-256 hashes — the plaintext is shown once at provisioning and is not recoverable. Failures:

Status code Meaning
401 unauthorized Header missing, malformed, or the key is unknown.
403 client_disabled The key exists but has been disabled.

Tenancy. Every read is scoped to the authenticated client. An id belonging to someone else returns 404 not_found, never 403 — the API must not work as an existence oracle across tenants.

Respondent endpoints (/r/*, /api/c/*) take no API key: the link token is the credential.

POST /v1/keys/rotate — replace your own key

Authenticated by the key it replaces, so this is rotation, not recovery: it answers "this key should stop working", never "I lost my key". A lost key still needs provision-key.mjs in the container.

curl -X POST https://interviews.getmetrika.hu/v1/keys/rotate \
  -H "Authorization: Bearer $INTERVIEW_API_KEY"
{
  "clientId": "cms36wml30000ufn5s9x77prx",
  "clientName": "dev",
  "key": "ivk_<32 hex>",
  "revokedSessions": 2,
  "note": "Store this key now — only its hash is kept…"
}

The old key stops working the moment this returns, and live OAuth grants are revoked with it (revokedSessions counts them). Those grants descend from the old key, so leaving them alive would make a rotation cosmetic — a connected MCP client must authorize again with the new key. The new plaintext is in this response and nowhere else.

OAuth for MCP clients

Some MCP clients — Claude Desktop, claude.ai — cannot send a static header. They discover and run an OAuth flow instead. The service is its own authorization server, and the API key is the credential: there are no user accounts (decision S13). An OAuth access token resolves to the same ApiClient as the key that authorized it, so it grants nothing extra.

Discovery (both unauthenticated, CORS-open; also served under the /api/mcp path suffix that some clients probe):

Endpoint Contents
GET /.well-known/oauth-protected-resource RFC 9728: the resource and which authorization server protects it
GET /.well-known/oauth-authorization-server RFC 8414: issuer, endpoints, S256 PKCE, public clients

Flow:

  1. POST /oauth/register (RFC 7591) — {client_name, redirect_uris[]} → a client_id. Public clients only; no secrets. Redirect URIs must be https (http allowed on loopback) and are later matched exactly.
  2. GET /oauth/authorize?... — renders a page naming the client and asking for an API key. PKCE with code_challenge_method=S256 is required. An unknown client or a redirect URI that does not match is an error page on our own origin, never a redirect — an unvalidated redirect is an open redirect.
  3. POST /oauth/token — authorization_code with code_verifier, or refresh_token. Codes are single-use, short-lived and PKCE-bound; refresh rotates. Replaying a used code or a rotated refresh token revokes the whole grant chain.

Codes and tokens are stored SHA-256-hashed, like API keys.

An unauthenticated MCP request answers 401 with

WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource"

so a client discovers the endpoints instead of guessing them.

Static keys keep working. Claude Code connects with a header:

claude mcp add --transport http interview https://interview.getmetrika.hu/api/mcp --header "Authorization: Bearer ivk_..."

Not implemented yet: token revocation (RFC 7009 — the only lever is disabling the whole client), rate limiting on the authorize form and on registration, and cleanup of expired codes and revoked tokens.


4. Conventions


5. The interview config

The config object is the same for a single interview and a campaign. Only goal, checklist and language.start are required.

{
  "goal": "Understand why this customer downgraded their plan.",

  "checklist": [
    {
      "id": "TRIGGER",                       // ^[A-Za-z][A-Za-z0-9_]*$ — UPPER_SNAKE recommended
      "label": "Downgrade trigger",          // required, non-empty
      "description": "What specifically prompted the change.",   // required, non-empty
      "question": "What made you decide to move down a plan?",   // optional guide, not a script
      "required": true                       // required items must be covered for completion
    }
  ],

  "phases": [                                // omit for the default OPENING → COVERAGE → CLOSING
    {"id": "OPENING", "label": "Opening", "description": "Frame the conversation."}
  ],

  "persona": {
    "name": "Anna",                          // the interviewer introduces itself with this
    "tone": "professional",                  // professional | casual | empathetic | direct
    "intro": "I'm a researcher on the product team.",
    "character": "Warm, unhurried, curious. Lets silences sit."
  },

  "language": {
    "start": "hu",                           // REQUIRED. BCP-47-ish
    "followRespondent": true                 // switch language if they reply in another. Default true
  },

  "context": "This account has been with us 3 years and downgraded last week.",
  "opening": {"message": "Szia! Köszönjük, hogy időt szánsz erre."},   // verbatim first message, no model call
  "rules": ["Never promise a refund.", "Do not mention the pricing experiment."],
  "piiPolicy": "refuse",                     // refuse (default) | allow
  "expectedDurationMinutes": {"min": 10, "max": 15},

  "limits": {
    "maxTurns": 40,
    "maxDurationMinutes": 45,
    "inactivityTimeoutMinutes": 120,         // default 120
    "linkExpiresAt": "2026-08-10T12:00:00Z"  // default: created + 14 days
  },

  "resultSchema": {                          // JSON Schema, root type MUST be "object"
    "type": "object",
    "properties": {"reason": {"type": "string"}, "wouldReturn": {"type": "boolean"}}
  },

  "tools": [ /* see §11 */ ],

  "respondentNotice": {                      // extra trust cards on the welcome screen
    "cards": [{"title": "Anonymous", "body": "Your name is not attached to this."}]
  },

  "theme": {                                 // per-client colors on the hosted page (S26)
    "accent": "#0f6fff",                     // optional; strict #rrggbb
    "background": "#f4f6fb",                 // optional; strict #rrggbb
    "mode": "auto"                           // optional; "auto" | "light" | "dark" (S26 §5)
  },                                         // at least one key required — {} is an error

  "onboarding": {                            // your own page, before the single static page (S27)
    "title": "Miért kérdezünk?",             // optional; defaults to a generic heading
    "body": "Pár mondat a kitöltőnek...",    // plain text; newlines = paragraphs
    "cards": [{"title": "5 perc", "body": "Ennyit vesz igénybe."}]
  },

  "completion": {                            // post-interview button to YOUR page (S25)
    "redirectUrl": "https://example.com/result/{interviewId}",
    "buttonLabel": "See your result"         // optional; defaults to "Continue"/"Tovább"
  },

  "callback": {                              // see §10
    "url": "https://example.com/hooks/interview",
    "secret": "whsec_...",
    "events": ["interview.completed"]
  },

  "metadata": {"accountId": "acc_123"}       // opaque to us, echoed back everywhere
}

Field notes

Unknown keys → warnings, not errors

The config schema is deliberately open: an unrecognized top-level key is stored and ignored, and reported back in warnings, with a spelling suggestion when it is close to a real field.

"warnings": [
  {"path": "config.piiPollicy", "message": "unknown field — did you mean \"piiPolicy\"? It was stored but has no effect."}
]

An empty warnings array is the normal case. A non-empty one almost always means a typo.


6. Interviews API

POST /v1/interviews — create

Headers: Authorization, optional Idempotency-Key. Body: {"config": { ... }} — or, launching from a stored archetype, {"agentId": "...", "config": { ...slots... }} (see §8).

201 →

{
  "id": "clx...",
  "status": "created",
  "respondentUrl": "http://localhost:3005/i/rt_9f...",
  "respondentToken": "rt_9f...",
  "promptCoreVersion": "2.0.0",
  "resolved": {
    "phases": ["OPENING", "COVERAGE", "CLOSING"],
    "usingDefaultPhases": true,
    "checklist": ["USAGE", "FRICTION"],
    "requiredChecklist": ["FRICTION"],
    "language": "en",
    "toolCount": 0,
    "hasResultSchema": false,
    "limits": {"maxTurns": null, "maxDurationMinutes": null, "inactivityTimeoutMinutes": null}
  },
  "warnings": [],
  "createdAt": "2026-07-27T10:00:00.000Z",
  "expiresAt": "2026-08-10T10:00:00.000Z",
  "metadata": {"accountId": "acc_123"}
}

resolved is the service telling you how it understood your config — which phase plan is in effect, which checklist ids the interviewer will use, what the limits became. Check it once when integrating.

Errors: 400 invalid_json, 400 invalid_request (body not an object), 422 invalid_config with a details array of {path, message}.

GET /v1/interviews — list

Query: status (one of the six statuses), limit (default 50, capped 100), cursor.

200 → {"data": [InterviewResource, ...], "nextCursor": "clx..." | null}

?since= — the inbox: what changed while you were away

Pass since and this becomes a different query: the interviews that moved since a watermark, oldest change first. It is the tool for coming back after a while, as opposed to interview_status?waitSeconds which is for waiting now.

Call it once with no since, then keep passing back the nextSince you were given. Each call resumes exactly where the last one stopped — nothing repeated, nothing skipped, even for two interviews that changed in the same millisecond. An interview appears at most once per call however much it moved.

curl "https://interview.getmetrika.hu/v1/interviews?since=MjAyNi0wNy0yOFQx...&limit=20" \
  -H "Authorization: Bearer $INTERVIEW_API_KEY"

200 → {"data": [...], "nextSince": "MjAyNi0...", "hasMore": false}

The watermark is opaque — pass it back unmodified. A watermark this service did not issue is a 400, rather than silently starting from the beginning.

POST /v1/interviews/agent-preview — try a config before anyone sees it

Runs the config as a full interview against a simulated respondent and returns the transcript. Nothing is created: no interview, no link, no record — a preview never appears in the list above.

POST and not GET on purpose: it spends real model calls, and a safe-method verb invites caches and prefetchers to spend them for you.

curl -X POST https://interview.getmetrika.hu/v1/interviews/agent-preview \
  -H "Authorization: Bearer $INTERVIEW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "config": { "goal": "...", "checklist": [...], "language": {"start": "en"} },
    "persona": "A guarded operations lead who suspects this is a sales call.",
    "maxTurns": 6
  }'

200 →

{
  "transcript": [
    {"role": "interviewer", "content": "…", "toolCalls": [{"name": "update_phase", "args": {"phase": "COVERAGE"}}]},
    {"role": "respondent", "content": "…"}
  ],
  "coverage": {"LAST_USE": "covered", "MISSING_VALUE": "covered"},
  "phase": "CLOSING",
  "respondentTurns": 5,
  "stoppedBecause": "finished",
  "warnings": [],
  "note": "Simulated: the respondent is a model playing a character, not a person…"
}

stoppedBecause is finished when the interviewer closed by itself — what a well-formed config should reach — or turn_limit when it was still going. maxTurns defaults to 6 and is capped at 12; each turn is two model calls.

Preview with your hardest respondent, not a cooperative one: a config that survives a reluctant person will survive anyone. And read the note — this shows whether your config works, not what real people will say.

An invalid config is a 422 with the same field paths POST /v1/interviews returns.

The preview also takes agentId/agentName + slots (§8) — the way to try an archetype exactly as a launch would resolve it.

GET /v1/interviews/{id} — status

This is the endpoint you poll. 200 →

{
  "id": "clx...",
  "status": "in_progress",
  "respondentUrl": "http://localhost:3005/i/rt_9f...",
  "respondentToken": "rt_9f...",
  "createdAt": "2026-07-27T10:00:00.000Z",
  "expiresAt": "2026-08-10T10:00:00.000Z",
  "progress": {
    "phase": "COVERAGE",
    "coverage": {"USAGE": "covered", "FRICTION": "pending"},
    "turns": 7,
    "startedAt": "2026-07-27T10:04:11.000Z",
    "lastActivityAt": "2026-07-27T10:12:40.000Z"
  },
  "result": null,
  "metadata": {"accountId": "acc_123"},
  "callbackStatus": {"state": "pending", "attempts": 0}
}
"result": {
  "closeReason": "goal_reached",
  "endedAt": "2026-07-27T10:31:02.000Z",
  "turns": 14,
  "coverage": {"USAGE": "covered", "FRICTION": "covered"},
  "transcriptUrl": "/v1/interviews/clx.../transcript",
  "data": {"reason": "price", "wouldReturn": true},
  "error": "…only present when extraction failed"
}

POST /v1/summary-links — a page a person can open

Mints a read-only page showing how an interview or campaign is going. Pass exactly ONE of interviewId or campaignId.

curl -X POST https://interview.getmetrika.hu/v1/summary-links \
  -H "Authorization: Bearer $INTERVIEW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"campaignId": "cam_123", "expiresInHours": 168}'

201 → {"id": "...", "url": "https://…/s/sum_…", "expiresAt": "…", "includesTranscript": false}

The link is not an API key: it is scoped to that one resource, expires, and can be revoked on its own. That is deliberate — a key in a URL ends up in browser history, in the Referer of every outbound link and in every proxy log between you and the reader.

includeTranscript defaults to false, and leaving it there is usually right. A link is shareable by nature; the respondent was told the commissioning client could read the conversation, not whoever the link reaches. The default page shows status, coverage and the extracted result. Turn the transcript on only for people entitled to read what was actually said.

PATCH /v1/interviews/{id} — fix one nobody has opened

Replaces the whole config while the status is still created. Send the complete config, not a patch — it replaces what was there, and the coverage state is rebuilt from it.

curl -X PATCH https://interview.getmetrika.hu/v1/interviews/clx123 \
  -H "Authorization: Bearer $INTERVIEW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"config": { "goal": "...", "checklist": [...], "language": {"start": "hu"} }}'

200 → the status resource, plus warnings.

409 already_started once someone has begun. That is not a limitation to work around: the config is the record of the rules the conversation actually ran under, and rewriting it would leave the transcript explained by rules that were never in force. To influence a live interview, use POST /v1/interviews/{id}/guidance.

DELETE /v1/interviews/{id} — delete content

Closes the interview immediately and hard-deletes the transcript and event log. The row survives (so you can see the deletion took effect); the conversation is gone and not recoverable.

200 → the interview resource as it stands after deletion.

POST /v1/interviews/{id}/close

Body (optional): {"mode": "graceful" | "immediate", "reason": "..."}. Default mode is graceful.

200 → {"status": "in_progress", "closing": true, "mode": "graceful"} 409 already_closed if the interview has already ended.

POST /v1/interviews/{id}/guidance

Body: {"note": "They hinted at a billing dispute — explore it before closing."}

The note reaches the model as a hidden developer message before its next turn. The respondent never sees it and is never told it exists. It steers — say what to pursue, not the words to say.

202 → {"accepted": true, "queuedNotes": 1} 409 not_open if the interview has ended. 422 invalid_request if the note is empty or over 2000 characters.

GET /v1/interviews/{id}/transcript

Query: since_seq (non-negative integer) returns only what is newer — this is the polling-based live read for clients that do not hold an SSE connection open. Available during the interview, not only after it.

200 →

{
  "interviewId": "clx...",
  "messages": [
    {"seq": 2, "role": "assistant", "content": "Hi — thanks for taking the time.", "at": "..."},
    {"seq": 3, "role": "user", "content": "Sure.", "at": "..."}
  ],
  "toolEvents": [
    {"seq": 9, "name": "lookup_account", "args": {"plan": "pro"}, "at": "...", "kind": "result", "ok": true, "durationMs": 214}
  ],
  "guidance": [{"seq": 11, "note": "Ask about billing.", "at": "..."}],
  "checklist": {"phase": "COVERAGE", "coverage": {"USAGE": "covered"}},
  "maxSeq": 12
}

Pass the maxSeq you received as the next since_seq. 422 invalid_query if since_seq is not a non-negative integer.


7. Campaigns API

A campaign is a link, not a conversation. Every person who opens it gets their own private interview with its own id, transcript and result. Reach for a campaign when many people should answer the same brief.

POST /v1/campaigns

Headers: Authorization, optional Idempotency-Key. Body: {"config": {...}, "maxResponses": 50, "closesAt": "2026-08-01T00:00:00Z"}

config is validated by the same validator as a single interview. The body also takes agentId/agentName + slots to launch from a stored archetype (§8). maxResponses must be a positive integer; closesAt an ISO-8601 timestamp. Both are optional — but a link without either keeps handing out interviews indefinitely, and links get forwarded.

201 →

{
  "id": "clx...",
  "status": "open",
  "shareUrl": "http://localhost:3005/c/share_a1...",
  "shareToken": "share_a1...",
  "createdAt": "2026-07-27T10:00:00.000Z",
  "closesAt": "2026-08-01T00:00:00.000Z",
  "maxResponses": 50,
  "responses": {"total": 0, "byStatus": {}},
  "metadata": null,
  "warnings": []
}

shareUrl and shareToken are absent once the campaign is closed — a closed link is dead and will refuse.

GET /v1/campaigns

Query: status (open | closed), limit (default 50, capped 100), cursor. 200 → {"data": [CampaignResource, ...], "nextCursor": ...}

GET /v1/campaigns/{id}

200 → the campaign resource, including live responses counts broken down by interview status.

POST /v1/campaigns/{id}/test-link — try it yourself, produce nothing

Returns a link that opens the campaign exactly as a respondent sees it, without creating a real response.

curl -X POST https://interview.getmetrika.hu/v1/campaigns/cam_123/test-link \
  -H "Authorization: Bearer $INTERVIEW_API_KEY"

201 → {"respondentUrl": "…", "interviewId": "…", "expiresInHours": 24, "note": "…"}

The run it creates does not consume a seat in the quota, appears in no listing or result set, is never harvested, triggers no callback, and is deleted within a day. Use this instead of opening the shared link "just to check" — that produces an answer that counts.

POST /v1/interviews takes the same idea as a flag: {"config": …, "test": true}.

Not the same thing as POST /v1/interviews/agent-preview, which has a MODEL play the respondent and returns a transcript. This one is for a human to click.

PATCH /v1/campaigns/{id} — edit for everyone who has not opened it yet

Changes the campaign's config, maxResponses or closesAt. Any subset; config is a full replacement.

curl -X PATCH https://interview.getmetrika.hu/v1/campaigns/cam_123 \
  -H "Authorization: Bearer $INTERVIEW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"config": { "goal": "...", "checklist": [...], "language": {"start": "hu"} }}'

200 → the campaign resource, plus warnings.

This is safe by construction, and it is worth knowing exactly why: a campaign config is a template that each response copies when someone opens the link. An edit therefore reaches only people who arrive after it. Responses already finished or under way keep running on the config they started with.

The consequence matters more than the mechanism: you cannot retro-fit a new question onto answers already given. If the change is important for the people who already answered, ask them separately.

409 campaign_closed on a closed campaign, where the change could reach nobody.

Stops the link from handing out new interviews. Conversations already running are left alone to finish on their own terms.

200 → the campaign resource, now closed and without shareUrl.

GET /v1/campaigns/{id}/interviews

Query: status, limit (default 50, capped 100), cursor. 200 → {"data": [InterviewResource, ...], "nextCursor": ...} — the same resource shape as a single interview, so you can walk them with the transcript and status endpoints you already use.

GET /v1/campaigns/{id}/results — what the campaign found out

/interviews above lists which conversations happened; this returns what they said — every response's extracted result in one call, instead of one status request per person.

Query: status (default completed, or all), limit (default 20, capped 50 — a full result is much heavier than a summary), cursor.

curl "https://interview.getmetrika.hu/v1/campaigns/cam_123/results" \
  -H "Authorization: Bearer $INTERVIEW_API_KEY"

200 →

{
  "campaignId": "cam_123",
  "summary": {"total": 24, "completed": 19, "withResult": 18, "withResultError": 1},
  "fields": ["lastUse", "missingValue"],
  "data": [
    {
      "interviewId": "clx...",
      "status": "completed",
      "closeReason": "goal_reached",
      "endedAt": "2026-07-28T09:14:02.114Z",
      "turns": 11,
      "coverage": {"LAST_USE": "covered", "MISSING_VALUE": "no_signal"},
      "metadata": {"seat": 4},
      "result": {"lastUse": "…", "missingValue": "…"},
      "resultError": null
    }
  ],
  "nextCursor": null
}

Two fields exist to stop you drawing the wrong conclusion:

fields is the union of result keys on this page — the columns of the table you are holding. A key present in only some rows is the visible symptom of a resultSchema the extraction could not always fill.

Quota safety

The response quota is enforced as a compare-and-swap on the counter, so two people opening the last slot at the same moment cannot both get in.


8. Agents API

An agent (decision S24) is a stored, named, PARTIAL interview config — a reusable archetype. The problem it solves: a config you developed and tested drifts a little every time the caller (typically an LLM over MCP) re-sends it from scratch. With an agent, the tested behavior lives on the server; the launch call supplies only the assignment-specific parts.

The split is by top-level field:

Freeze on create. The merged config is copied onto the interview or campaign row at launch — the same invariant campaign templates already obey. Editing an agent afterwards changes only future launches; everything already launched keeps its frozen copy. The launched row records agentId and the agent's agentRevision as provenance (campaigns also keep the caller's agentSlots), so the frozen copy is the history — there is no separate version table.

One agent-only validation rule: limits.linkExpiresAt is rejected on an agent config — an absolute expiry stored in a reusable template is always a bug. Supply it in the launch call instead.

POST /v1/agents — create

Headers: Authorization, optional Idempotency-Key. Body: {"name": "...", "description": "...", "config": { ...partial config... }}.

name is required, unique per client (max 120 chars) — 409 name_taken on a duplicate. config is validated as a partial config: everything present must be valid, required fields may be absent.

curl -s -X POST https://interview.getmetrika.hu/v1/agents \
  -H "Authorization: Bearer $INTERVIEW_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "satisfaction-survey",
    "description": "The tested Metrika satisfaction archetype.",
    "config": {
      "checklist": [
        {"id": "LAST_USE", "label": "Last use", "description": "When and what for."},
        {"id": "MISSING_VALUE", "label": "Missing value", "description": "What they hoped for and did not get.", "required": true}
      ],
      "language": {"start": "hu"},
      "persona": {"name": "Réka", "tone": "warm, curious"}
    }
  }'

201 →

{
  "agentId": "clx...",
  "name": "satisfaction-survey",
  "description": "The tested Metrika satisfaction archetype.",
  "revision": 1,
  "status": "active",
  "openSlots": ["goal"],
  "fixedFields": ["checklist", "language", "persona"],
  "config": { ... },
  "createdAt": "2026-08-04T10:00:00.000Z",
  "updatedAt": "2026-08-04T10:00:00.000Z",
  "warnings": []
}

openSlots lists the required fields the agent leaves open — what a launch call must supply. fixedFields lists what it must not supply (metadata never appears here, since it merges).

GET /v1/agents — list

Query: status (active — the default — | archived | all), limit (default 25, capped 100), cursor.

200 → {"agents": [AgentSummary, ...], "nextCursor": "clx..." | null} — the summary is the resource above without config.

GET /v1/agents/{id} — read

200 → the full agent resource, config included.

PATCH /v1/agents/{id} — edit future launches

Any subset of {"config": ..., "name": ..., "description": ...}. config is a full replacement (the repo's config-edit convention) and bumps revision; a name/description edit does not, because nothing launched from the agent depends on those.

200 → the updated resource + warnings. Errors: 404 not_found, 409 agent_archived (an archived agent is not editable — create a new one), 409 name_taken, 422 invalid_config.

Remember the freeze: this affects only launches after the call. Mid-wave edits cannot corrupt a running campaign's comparability — responses already minted keep the config they started with.

DELETE /v1/agents/{id} — archive, not delete

200 → the resource with status: "archived". Idempotent. An archived agent refuses new launches (409 agent_archived), disappears from the default listing, but stays readable by id — launched rows keep their provenance pointer, and running campaigns spawned from it continue undisturbed.

Launching from an agent

POST /v1/interviews, POST /v1/campaigns and POST /v1/interviews/agent-preview all accept an agent reference:

{
  "agentId": "clx...",        // or "agentName": "satisfaction-survey"
  "config": {
    "goal": "Understand why this account went quiet in July.",
    "metadata": {"accountId": "acc_123"}
  }
}

With agentId (or agentName) present, config is the slots object — it fills what the agent left open. The response is the normal interview/campaign resource, plus agentId and agentRevision recording what it was resolved from. Failures:

Status code When
404 not_found No such agent for this key (or it belongs to another client).
409 agent_archived The agent is archived and refuses new launches.
422 agent_conflict The slots name fields the agent fixes; details lists every conflicting path.
422 invalid_config The merged config failed the full validator (e.g. a required open slot was not filled).

The agent_conflict details are self-healing for an LLM caller: drop the listed fields and re-call — or, if the field genuinely should vary per launch, remove it from the agent.

MCP twins: agent_create, agent_get, agent_list, agent_update, agent_delete, and the same agentId/agentName arguments on interview_create, campaign_create and interview_agent_preview (§12).


9. Live observation (SSE)

GET /v1/interviews/{id}/events

A Server-Sent Events stream over the append-only event log. Requires the API key. The stream ends when the interview reaches a terminal status.

Resume after a dropped connection with the standard Last-Event-ID header, or ?since_seq=N for clients that cannot set it. id: on each frame is the event sequence number.

id: 14
event: message
data: {"role":"assistant","content":"What made you decide to downgrade?","at":"2026-07-27T10:12:00.000Z"}

event: ping
data: {}
event: data
message {role, content, at} — assistant messages arrive complete, not token by token
coverage {itemId, status, at} — a checklist item moved to covered / no_signal
phase {phase, at}
guidance_applied {note, at} — a note you sent was actually applied to a turn
tool_result {name, args, ok, error?, durationMs?, at} — a client-defined tool lookup and its outcome
status {status, closeReason, at}
ping {} — keepalive, no sequence number

The interviewer's internal bookkeeping tool calls (set_item_coverage, update_phase) are not in the feed — the coverage and phase events already say what they changed.

If you would rather poll than hold a connection open, use GET /v1/interviews/{id}/transcript?since_seq=N instead. Same information, same cursor.


10. Webhooks

Configure with config.callback:

"callback": {
  "url": "https://example.com/hooks/interview",
  "secret": "whsec_...",
  "events": ["interview.completed", "interview.abandoned"]
}

url must be https (plain http is allowed only against localhost). events defaults to the terminal events only.

Event Fires when
interview.started The respondent opened the link and began. Opt-in — not in the default set.
interview.completed The interview reached its goal or was closed after covering it.
interview.abandoned Inactivity timeout.
interview.expired The link expired before/while it was used.
interview.cancelled You closed it (immediate), or deleted its content.

Payload

{
  "id": "evt_3c...",
  "type": "interview.completed",
  "at": "2026-07-27T10:31:02.000Z",
  "interview": { /* the full status resource, as in GET /v1/interviews/{id} */ }
}

Dedupe on id.

Signature

When secret is set, every delivery carries:

X-Interview-Signature: t=1785312662,v1=<hex hmac-sha256>

The MAC is HMAC_SHA256(secret, "<t>.<raw request body>") — the timestamp is part of the signed string, so a captured body cannot be re-stamped and replayed. Verify against the raw body bytes, before JSON parsing, and reject timestamps outside your tolerated skew.

const [t, v1] = header.split(',').map((p) => p.split('=')[1])
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))

Delivery guarantees

"callbackStatus": {"state": "failed", "attempts": 6, "lastError": "HTTP 502"}

state is the worst case across this interview's deliveries (failed > pending > delivered; none when nothing was ever enqueued).


11. Client-defined tools

config.tools registers read-only lookups the interviewer may consult mid-conversation — an order status, a plan, an account tier.

"tools": [
  {
    "name": "lookup_order",
    "description": "Look up an order by its number. Use when the respondent mentions one.",
    "parameters": {
      "type": "object",
      "properties": {"orderNumber": {"type": "string", "description": "The order number."}},
      "required": ["orderNumber"]
    },
    "endpoint": {"url": "https://example.com/tools/orders", "secret": "whsec_..."},
    "boundArgs": {"accountId": "acc_123"},
    "timeoutMs": 5000
  }
]

Rules:

The service POSTs the merged arguments to your endpoint, signed with the same X-Interview-Signature scheme as webhooks. Responses are capped at 64 KB. The call and its outcome appear in the transcript (toolEvents) and in the live feed (tool_result) — with only the model-supplied arguments, never your bound ones.

Read-only by contract. Nothing a tool returns can write into interview state; the result is data handed to the model and nothing else.


12. MCP server

POST /api/mcp — Streamable HTTP, stateless, authenticated with the same Authorization: Bearer ivk_... key. Only POST exists; GET and DELETE answer 405, which is what the spec tells clients to expect from a server that offers neither.

Every MCP tool calls the same service functions as REST, so there is no capability here that the REST API lacks — and the same tenancy applies: an agent only ever sees the interviews its own key created.

Example client config:

{
  "mcpServers": {
    "interview-service": {
      "url": "https://interviews.example.com/api/mcp",
      "headers": {"Authorization": "Bearer ivk_..."}
    }
  }
}

The twenty-one tools

Tool Arguments Returns
interview_create config, agentId?/agentName?, idempotencyKey? {interviewId, respondentUrl, respondentToken, status, warnings}
interview_status interviewId, waitSeconds? The full status resource
interview_transcript interviewId, sinceSeq?, limit? {messages, toolEvents, guidance, checklist, maxSeq, hasMore}
interview_guide interviewId, note {accepted: true, queuedNotes}
interview_close interviewId, mode?, reason? {status, closing, mode}
interview_list status?, limit?, cursor? {interviews: [summary], nextCursor}
interview_inbox since?, status?, limit? {interviews: [entry], nextSince, hasMore}
interview_agent_preview config, agentId?/agentName?, persona?, maxTurns? {transcript, coverage, phase, stoppedBecause, note}
interview_update interviewId, config The status resource + warnings (only while created)
campaign_create config, agentId?/agentName?, maxResponses?, closesAt?, idempotencyKey? {campaignId, shareUrl, shareToken, status, warnings}
campaign_status campaignId The campaign resource with response counts
campaign_responses campaignId, status?, limit?, cursor? {interviews: [summary], nextCursor}
campaign_results campaignId, status?, limit?, cursor? {summary, fields, responses, nextCursor}
campaign_update campaignId, config?, maxResponses?, closesAt? The campaign resource + warnings
campaign_test_link campaignId {respondentUrl, interviewId, expiresInHours, note}
summary_link interviewId? | campaignId?, includeTranscript?, expiresInHours? {url, expiresAt, includesTranscript, note}
agent_create name, config (partial), description?, idempotencyKey? The agent summary: {agentId, name, revision, openSlots, fixedFields, …} + warnings
agent_get agentId? | name? The full agent resource, config included
agent_list status? (active/archived/all), limit?, cursor? {agents: [summary], nextCursor}
agent_update agentId, config?, name?, description? The updated resource + warnings + a note that only future launches change
agent_delete agentId {agentId, status: "archived", note} — archive, not delete

Notes that matter in practice:

Failed tool calls come back as isError results carrying the same {code, message} vocabulary as REST. Authentication failures are JSON-RPC protocol errors with the matching HTTP status, not empty tool lists.


13. Respondent endpoints

These power the hosted respondent page. You normally do not call them — you hand out the link and let the page do its work. They are documented because they are also the contract if you host your own respondent UI.

No API key. The token in the path is the credential.

GET /r/{token}/session

Bootstraps a session and returns server-owned state, so a reload or a different device resumes exactly where the respondent left off.

200 → {interviewId, status, language, personaName, phases: [{id, label}], checklist: [{id, label}], progress: {phase, coverage}, notice, messages, expiresAt, completion?}

notice is the trust baseline (fixed; since S28 the single AI-disclosure card) plus any respondentNotice.cards you configured. The welcome page also renders a mandatory consent checkbox linking to /privacy — client config cannot remove it. completion is the resolved post-interview button (S25): {url, label?} with {interviewId} already substituted — the page renders it on the closing screen only.

404 not_found for an unknown token; 410 ended / 410 expired otherwise — kept apart on purpose, because "thank you, we're done" and "your link ran out" are very different things to read. A 410 ended body also carries completion when the interview actually ended completed, so a respondent who reloads after finishing keeps their way forward — an abandoned or cancelled interview never gets the link.

POST /r/{token}/chat

Body: {"message": "..."}. The client posts only its new message — no history, no checklist state; the server owns both. An empty message is legitimate exactly once: as the request for the interviewer's opening turn.

Responds with an SSE stream of the engine protocol: delta, tool_call, tool_result, meta, hint, finish, done, error (each frame is data: {"type": ...}).

Errors: 410 ended / 410 expired, 422 invalid_message (empty, or over 10 000 characters), 404 not_found. The 410 ended body carries completion ({url, label?}) when the server closed the interview as completed in between turns — same rule as the session route.

POST /api/c/{token}/join

The shared link's entry point. Mints a personal interview from a campaign and returns {"respondentToken": "rt_..."} (201).

Deliberately a POST: link unfurlers in Slack, WhatsApp and iMessage fetch shared URLs, and a GET that spawned an interview would burn a response slot every time someone pasted the link into a chat. The /c/{token} page loads inert and joins only on a real visitor's action.

Errors: 404 not_found; 410 with code closed (campaign closed or past its deadline) or full (quota reached).


14. Errors

{"error": {"code": "invalid_config", "message": "The interview config is invalid.",
  "details": [{"path": "config.checklist[0].label", "message": "label must be non-empty"}]}}
Status code When
400 invalid_json The body is not valid JSON.
400 invalid_request The body is not an object; unknown status filter; non-integer limit.
401 unauthorized Missing, malformed or unknown API key.
403 client_disabled The key has been disabled.
404 not_found No such id — or it belongs to another client.
409 already_closed Closing an interview that has already ended.
409 not_open Sending guidance to an interview that has ended.
409 name_taken An agent with that name already exists for this key.
409 agent_archived Launching from — or editing — an archived agent.
410 ended / expired / closed / full Respondent- and campaign-link states.
422 invalid_config Config validation failed; see details.
422 agent_conflict Launch slots name fields the agent fixes; details lists each path.
422 invalid_request Bad mode, empty/oversized guidance note, bad maxResponses/closesAt.
422 invalid_query Bad since_seq.
422 invalid_message Respondent message empty or too long.
500 internal_error Ours. The message is deliberately generic.

A 422 from validation never throws and never becomes a 500: malformed input of any shape comes back as a field-path error list.


15. Limits and lifecycle

Statuses

created → in_progress → completed | abandoned | expired | cancelled

The four terminal statuses have no transition out. closeReason explains which way it went: goal_reached, respondent_ended, client_requested, max_turns, max_duration, inactivity, expired.

What ends an interview

Cause Result
The interviewer reaches the goal completed, goal_reached
The respondent ends it completed, respondent_ended
limits.maxTurns reached completed, max_turns
limits.maxDurationMinutes elapsed since start completed, max_duration
No activity for limits.inactivityTimeoutMinutes (default 120) abandoned, inactivity
limits.linkExpiresAt passed (default: created + 14 days) expired, expired
POST .../close (graceful) completed, client_requested — after one more turn
POST .../close (immediate) or DELETE cancelled, client_requested

Timeouts and expiry are applied by an in-process sweeper that runs every 60 s by default (INTERVIEW_SWEEPER_INTERVAL_MS, INTERVIEW_SWEEPER=off). The same pass runs result extraction and webhook delivery. A multi-instance deployment must move the sweeper to a single owner, or every instance sweeps in parallel.

Hard caps

Thing Limit
Respondent message 10 000 characters
Guidance note 2 000 characters
Client tools per interview 8
Client tool response 64 KB
Client tool timeout 500–15 000 ms (default 5 000)
List page size 100 (default 50; MCP default 20)
MCP transcript page 500 events (default 200)
MCP waitSeconds 60 s
Webhook attempt timeout 10 s
Webhook attempts 6 (1m, 5m, 30m, 2h, 8h)

Environment

Variable Purpose
DATABASE_URL SQLite locally, Postgres in production.
APP_URL Public origin. respondentUrl and shareUrl are built from it.
OPENAI_API_KEY Read from the repo-root .env; the engine needs it.
INTERVIEW_SWEEPER off disables the in-process sweeper.
INTERVIEW_SWEEPER_INTERVAL_MS Sweep interval, default 60 000.

16. Recipes

Run one interview end to end

# 1. Create
ID=$(curl -s -X POST "$BASE/v1/interviews" -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"config":{"goal":"Understand the downgrade.","checklist":[{"id":"TRIGGER","label":"Trigger","description":"What prompted it.","required":true}],"language":{"start":"en"},"resultSchema":{"type":"object","properties":{"reason":{"type":"string"}}}}}' \
  | tee /dev/stderr | jq -r .id)

# 2. Send the respondentUrl to the person yourself. Then watch:
curl -s -N "$BASE/v1/interviews/$ID/events" -H "Authorization: Bearer $KEY"

# 3. Steer mid-conversation
curl -s -X POST "$BASE/v1/interviews/$ID/guidance" -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"note":"They mentioned a billing dispute — explore it."}'

# 4. Harvest
curl -s "$BASE/v1/interviews/$ID" -H "Authorization: Bearer $KEY" | jq .result

Poll instead of streaming

SEQ=0
while :; do
  RESP=$(curl -s "$BASE/v1/interviews/$ID/transcript?since_seq=$SEQ" -H "Authorization: Bearer $KEY")
  echo "$RESP" | jq -c '.messages[]'
  SEQ=$(echo "$RESP" | jq -r '.maxSeq')
  STATUS=$(curl -s "$BASE/v1/interviews/$ID" -H "Authorization: Bearer $KEY" | jq -r .status)
  case "$STATUS" in completed|abandoned|expired|cancelled) break;; esac
  sleep 5
done
CAMPAIGN=$(curl -s -X POST "$BASE/v1/campaigns" -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"config":{"goal":"Team health check.","checklist":[{"id":"WORKLOAD","label":"Workload","description":"How sustainable it feels."}],"language":{"start":"hu"}},"maxResponses":40,"closesAt":"2026-08-15T00:00:00Z"}')

echo "$CAMPAIGN" | jq -r .shareUrl        # share this one link with everyone

# Later: harvest only the finished ones
curl -s "$BASE/v1/campaigns/$(echo "$CAMPAIGN" | jq -r .id)/interviews?status=completed" \
  -H "Authorization: Bearer $KEY" | jq -r '.data[].id'

Store an archetype once, launch it many times

# 1. Store the tested behavior — everything but the per-assignment parts
curl -s -X POST "$BASE/v1/agents" -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"name":"churn-interview","config":{"checklist":[{"id":"TRIGGER","label":"Trigger","description":"What prompted the change.","required":true}],"language":{"start":"en"},"persona":{"name":"Alex","tone":"warm, direct"}}}'

# 2. Preview it as a launch would resolve it (nothing is created)
curl -s -X POST "$BASE/v1/interviews/agent-preview" -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"agentName":"churn-interview","config":{"goal":"Understand why acc_123 downgraded."}}'

# 3. Launch — config is just the slots; the tested parts cannot drift
curl -s -X POST "$BASE/v1/interviews" -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"agentName":"churn-interview","config":{"goal":"Understand why acc_123 downgraded.","metadata":{"accountId":"acc_123"}}}'

If step 3 answers 422 agent_conflict, the slots named a field the agent already fixes — drop it and re-call, or move it out of the agent.

Retry safely

curl -s -X POST "$BASE/v1/interviews" -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: order-4711-interview" \
  -H 'Content-Type: application/json' -d @config.json

Repeating that exact call returns the original interview instead of creating a second one — including when two retries race each other.