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
- Concepts
- Getting started
- Authentication
- Conventions
- The interview config
- Interviews API
- Campaigns API
- Agents API
- Live observation (SSE)
- Webhooks
- Client-defined tools
- MCP server
- Respondent endpoints
- Errors
- Limits and lifecycle
- 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:
POST /oauth/register(RFC 7591) —{client_name, redirect_uris[]}→ aclient_id. Public clients only; no secrets. Redirect URIs must be https (http allowed on loopback) and are later matched exactly.GET /oauth/authorize?...— renders a page naming the client and asking for an API key. PKCE withcode_challenge_method=S256is 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.POST /oauth/token—authorization_codewithcode_verifier, orrefresh_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
- JSON in, JSON out.
Content-Type: application/jsonon every request with a body. - Never cached. Every response carries
Cache-Control: no-store. - Errors are always
{"error": {"code": "...", "message": "...", "details": [...]}}.detailsappears on validation failures. See §14. - Timestamps are ISO-8601 UTC strings.
- Ids: interviews and campaigns use cuids. Respondent tokens are
rt_<64 hex>, share tokensshare_<64 hex>, API keysivk_<32 hex>, webhook event idsevt_<32 hex>. - Idempotency.
POST /v1/interviewsandPOST /v1/campaignsaccept anIdempotency-Keyheader. Repeating a call with the same key replays the original resource instead of creating a second one. The key is scoped to your client, so two tenants may use the same string. - Pagination is cursor-based: pass
?limit=and?cursor=, readnextCursorfrom the response, stop when it isnull. Newest first. - Path forms.
/v1/...and/r/...are the canonical paths (rewritten internally to/api/v1/...and/api/r/...; both forms work). The MCP endpoint and the campaign join endpoint have no rewrite: they are/api/mcpand/api/c/{token}/join.
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
checklistmust be non-empty; ids must be unique and match^[A-Za-z][A-Za-z0-9_]*$. Lowercase ids are accepted (the design spec's hard UPPER_SNAKE rule was relaxed to a recommendation).phases, when given, must be non-empty and have unique ids. When omitted the default three-phase plan applies, and its labels are localized for Hungarian respondents. If you supply phases, the labels are yours as written.opening.messageis emitted verbatim as the first assistant turn and costs no model call.contextis internal knowledge for the interviewer, not something it recites to the respondent.resultSchemais not used during the conversation. After close, the transcript is harvested into it and the result appears atresult.dataon the status resource.respondentNotice.cardsare additions. The honesty baseline — since S28 the single AI disclosure ("you are talking to an AI interviewer") — is fixed and cannot be hidden, reordered or contradicted. The recording and client-access disclosures live in the hosted privacy notice (/privacy), which the respondent must explicitly accept via a checkbox before the start button activates.themecolors the hosted respondent page — the whole of it, including the ended screen. You supply few input colors (accent, optionallybackground); the server derives the full palette, light and dark variants, and the page only ever receives resolved values — there is no token-level override. Colors are strict#rrggbb(6-digit hex); anything else is a validation error, as is any key other thanaccent,backgroundandmode— no silent fallback to the stock look while you believe you themed it. A contrast guard protects readability: the critical pairs (text/background, accent/on-accent) are checked against WCAG 4.5:1 and auto-adjusted until they pass — the server never rejects a theme and never ships an unreadable one.mode(S26 §5) locks the page's color scheme:"auto"(default) follows the respondent's system setting — today's behavior;"light"disables dark mode entirely;"dark"locks the dark scheme. It works with or without custom colors — every theme key is optional, but a presentthememust set at least one of them (an empty{}is a validation error, never a silent no-op). Omit the field for the stock look.onboardingadds your own page — free text and/or cards in therespondentNoticecard shape — rendered as an extra page before the single static page (greeting + honesty baseline + start button): the static page is always last and keeps sole ownership of the start button, so your content can never sit between consent and start. Without this field the flow is that one static page alone. At least one ofbody/cardsis required when the field is present. Content is plain text (newlines become paragraphs; no images or formatting), and it ships in your own language — the service does not translate it. Limits:title80 chars,body4000, at most 6 cards, cardtitle80, cardbody1000.completionputs a button on the closing screen that takes the respondent to your page — never an automatic redirect: the interviewer's goodbye stays readable and leaving is the respondent's click. Shown only when the interview endscompleted; abandoned/expired/cancelled never offer it.redirectUrlis https-only (http on localhost) and supports exactly one placeholder,{interviewId}— any other{...}is a validation error, and metadata deliberately cannot be substituted (PII would end up in query strings). Mind the timing: the structuredresultis harvested asynchronously after close, so the respondent can reach your page before the result exists — give that page a brief "preparing" state that pollsGET /v1/interviews/{id}.limits.maxTurns— budget roughly 3–4 respondent turns per checklist item, plus 2 (the prompt-core 2.0 pacing, S23). When the limit is reached, the last allowed turn is a farewell, not one more question — the interview still endscompletedwithcloseReason: "max_turns", but with a proper closing line instead of being cut off mid-thread.metadatamust be a JSON object. It is echoed in status responses, campaign resources and webhook payloads.
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}
}
respondentUrl/respondentTokenare absent once the interview is terminal — the link is dead by then, so advertising it would be a lie.resultisnulluntil the interview ends. Once terminal:
"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"
}
callbackStatusis present only when a callback is configured. See §10.
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.
graceful— a request, not a state change. The interviewer wraps up on its next turn, so the respondent still gets a proper goodbye. The status staysin_progressuntil the respondent sends one more message.immediate— ends now and kills the link, cutting off anyone mid-conversation.
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.
DELETE /v1/campaigns/{id} — close the link
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:
summarycounts the whole campaign, whatever you filtered. Reading "18 responses" off a filtered page and calling it the campaign is the mistake this prevents.withResultErroris the responses that finished but whose harvest failed. From a list of statuses those look identical to unfinished ones, and they would silently shrink your denominator.
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:
- What the agent sets is fixed for every launch. A launch config naming a
fixed field is a loud 422
agent_conflictlisting every conflicting path — never a silent win for either side. There is deliberately no deep merge: an archetype that wants a customizablepersonasimply omitspersona. - What the agent omits is an open slot the launch call fills. Of the
engine-required fields (
goal,checklist,language), whichever the agent leaves open the launch call must supply — the merged result goes through the exact same full validator as a direct create. - The one exception is
metadata: it shallow-merges, launch keys winning, because it is behavior-free and per-use correlation is its whole point.
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
- Written to an outbox in the same transaction as the change they report — a callback cannot go missing because a process died at the wrong moment.
- Your endpoint gets 10 seconds to answer. Any 2xx counts as delivered.
- Retries after a failure: 1m, 5m, 30m, 2h, 8h — six attempts in total.
After that the delivery is marked
failedand left visible rather than retried forever. - Callbacks are a convenience, not the source of truth. Polling
GET /v1/interviews/{id}always works, andcallbackStatuson that resource tells you how delivery is going:
"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:
name:^[a-z][a-z0-9_]{0,47}$, and never one of the engine's built-in tool names. At most 8 tools per interview.parametersmust be a JSON Schema object (type: "object"withproperties). This is the model-visible contract.endpoint.urlmust be https (http allowed only for localhost). Obvious internal addresses (link-local, RFC1918 ranges) are rejected — note this is a coarse string check, not full SSRF protection.boundArgsare fixed server-side: the model never sees them in the schema and cannot override them, because they are merged over the model's arguments at call time. Put identifiers and tenant scopes here.timeoutMs: 500–15000, default 5000.
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:
Launching from an agent: pass
agentId(oragentName) tointerview_create/campaign_create/interview_agent_preview, andconfigbecomes the slots object. Conflicts with the agent's fixed fields are a loudagent_conflicterror listing every path — drop those fields and re-call. Full semantics in §8.interview_status.waitSecondsis a bounded long-poll: it returns as soon as the status or the checklist coverage moves, or when the wait expires. Capped at 60 s — watching an interview to its end means calling it in a loop. Turn count deliberately does not count as "a change", or small talk would turn the wait into a busy loop.interview_transcriptis paginated over the event log: default 200 events per call, max 500. PasssinceSeq: <last maxSeq>and keep going whilehasMore. An empty page returns thesinceSeqyou sent, so a polling loop cannot rewind.interview_list/campaign_responsesreturn compact summaries ({id, status, coverageSummary: "2/3 covered", lastActivityAt, metadata}), default page 20, max 100. Useinterview_statusfor the full picture of any one of them.Delivering the link is the agent's job. The service never contacts anyone.
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
Collect many responses from one link
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.