InterLogue
<!-- Staged draft, written during the unattended run of Sep 17 2026 on branch overnight/2026-09-17 and updated on Sep 18 2026 on branch overnight/2026-09-18. Not yet reviewed by Amine. -->
# InterLogue
Brief it the way an editor briefs a reporter. It interviews the subject and returns
a published piece with every quote cited to a timestamp in the transcript.
InterLogue is an MCP server. Inside a host such as Claude, the host writes the
piece and InterLogue is the fact-checker: nothing is stored as a piece until every
quoted span resolves, verbatim, to a subject turn at the cited timestamp. A product
with no host model calls `write_piece` instead and the server writes the piece, then
puts it through the same check. The spine is:
```
brief -> approve_contact -> interview (place_call + fetch_transcript, or run_interview) -> draft_piece -> [the host writes] -> check_citations
or -> write_piece (the server writes, same check)
```
The output leads with the story. Pull quotes come second, each with its timestamp.
The per-question answers come third, as a secondary view. Every piece ends with a
line that says whether a recorded call stands behind it.
## The one-sentence brief
The `brief` tool needs three things: the person's name, their phone number, and one
sentence in the user's words about who they are and what the piece is about.
> Interview Marisol Teague, +1-502-555-0142, founder of Ridgeline Provisions, about
> switching her order entry to Tallyhook, for Tallyhook's marketing team.
Role, company, client, product, topic, angle and genre are inferred from the sentence
or defaulted, and the result names every choice. Four genres share the same seven
story beats: a subject at another company than the client gets the customer case
study plan; a subject whose company is the client gets the founder story plan; a
sentence that says podcast or episode gets the podcast segment plan (a hook question
first, a closing question that invites a story); one that says testimonial gets the
testimonial plan (five beats, ending on a recommendation). An explicit `genre` field
overrides all of that, and `question_templates` lists what each genre asks. A phone
number, email address or street address inside the sentence is stripped before
anything is inferred, so it never becomes the angle.
## The three rules
1. **Output leads with the story.** A bulleted question-and-answer summary is meeting
minutes, not a piece. The checker fails a piece that is not prose first.
2. **No cold outreach.** A human approves the specific person and number before any
dial. The agent states it is an AI and asks permission to record at the top of
the call. A subject who declines to be recorded stops the interview: nothing is
stored.
3. **No agenda steering.** The user sets an angle, which shapes which questions are
asked and what is emphasized. It never shapes what the subject is portrayed as
having said. Every claim and quote links to a timestamp in the transcript.
## The tools as a host sees them
| Tool | Inputs | What it does |
| --- | --- | --- |
| `brief` | `subject_name`, `subject_phone`, `about`; optional `genre`, `subject_role`, `subject_company`, `client_company`, `client_product`, `topic`, `angle`, `content_needed` | Stores the brief and builds the question plan from the sentence, in one of four genres, and says how the genre was chosen. Nobody is contacted. |
| `approve_contact` | `brief_id`, `approved_by`, `statement`; optional `subject_name`, `phone`, `consent_basis`, `confirm` | Over stdio: records that a named human approved contacting this brief's subject at this brief's number. Over HTTP: records nothing and returns a one-time approval link for a person to click; only the click counts. A name or number that differs from the brief refuses. See "What the approval record is" below. |
| `run_interview` | `brief_id`; `fixture` or `turns` | Text-only interview from the fictional fixture or from turns supplied inline. Refuses without an approval on file and refuses a transcript whose subject declined to be recorded. |
| `place_call` | `brief_id`; optional `confirm_dial` (ignored) | Phone path, step 1. Refused by the dial gate without the approval record. Places one outbound call through the ElevenLabs agent over Twilio. Present only when the ElevenLabs variables are set. |
| `fetch_transcript` | `brief_id`; optional `wait_secs` | Phone path, step 2. Waits for the call to end, fetches and normalizes the transcript with real timestamps, runs the consent check, stores it append-only. A refusal to be recorded stores nothing and marks the call `consent_failed`. Answers at once for a call the post-call webhook already stored. Present only with the ElevenLabs variables. |
| `draft_piece` | `brief_id` | Returns the reporter's packet: brief, question plan, writing contract, ranked verbatim quote candidates with timestamps, and the full transcript, with contact details masked. |
| `check_citations` | `brief_id` + `markdown`, or `piece_id` | The fact-checker and, with `write_piece`, the only path by which a piece is stored. Validates section order, every quoted span and its `(MM:SS)`, numbers outside quotes, quotes in the dek or byline, repeated frames. On a pass it appends the per-question view, masks contact details, and stores the piece. With `piece_id` it re-checks a stored piece. |
| `write_piece` | `brief_id`; optional `writer` (`model` or `deterministic`) | The server writes the piece from the stored transcript, with the configured Claude model when `ANTHROPIC_API_KEY` is set (model from `INTERLOGUE_WRITER_MODEL`, default `claude-sonnet-5`) or the deterministic reporter otherwise, submits it to the same check as `check_citations`, retries once with the failing spans fed back, and stores only a clean pass. |
| `generate_piece` | `brief_id` | The deterministic fallback writer for the no-host run. Registered only when `INTERLOGUE_ALLOW_FALLBACK=1`. |
| `status` | `brief_id` | Where a brief sits in the spine and what to call next. Names a withdrawn brief as withdrawn. With a transcript on file it also names the planned beats the call never reached and the questions to send the subject by email. |
| `withdraw_subject` | `brief_id`, or `subject_name` + `phone`; `confirm`, `confirm_token`, optional `requested_by` | Replaces every record of a subject with tombstones at their request, after a preview. Returns the external references the operator must delete by hand. |
| `ledger` | optional `brief_id` | Cost and duration per brief and in total from the call records (credits and USD as ElevenLabs reported them), pieces per brief, a per-ten-minutes figure, and what one stored interview costs against the two hours of a marketer's time it replaces (dollars with `INTERLOGUE_MARKETER_HOURLY_USD`). Metadata only: no transcript is opened, no name or number printed. |
| `quote_check` | `brief_id`, optional `piece_id`; to relay an answer: `subject_response`, `disputed_quotes`, `note`, `relayed_by` | The subject sees exactly the words a stored piece attributes to them, each with its timestamp, and confirms or disputes them before anything is published. Hosted: a signed one-time page, delivered through the operator's channel when one is configured. Stdio: the message to send, and the answer relayed by a person. Recorded per piece, shown by `status`, withdrawn with the subject's other records. |
| `derive_assets` | `brief_id`, optional `piece_id` | A LinkedIn post, a short post under 280 characters and an email teaser from a stored piece, built only from the piece's own sentences and pull quotes, every quote re-verified against the transcript and kept with its timestamp. Nothing stored. |
| `discover_contacts` | `query` | Stub: "not in this build". Automated contact discovery is not part of InterLogue. |
| `question_templates` | optional `genre` | The four genres and the questions each asks, with placeholders. |
| `download_recording` | `transcript_id` | Stub: "not in this build". The timestamped transcript is the record. |
Every tool returns readable text: a heading line, the facts, then a `Next:` line
naming the tool to call next. A refusal sets `isError`. Phone numbers are never
echoed beyond their last four digits.
## A judge can try it two ways
Requires Node 20 or newer. No env file, no API keys.
### Path 1: one command, no Claude needed (the fallback writer)
```bash
git clone https://github.com/aminehamlouchi/interlogue.git interlogue && cd interlogue && npm install && npm run spine
```
`npm run spine` builds the server, starts it over stdio, and drives the tools in order
with the fictional fixture using the deterministic reporter as the writer. It prints
the generated piece and the citation check, and exits non-zero if any quote fails to
resolve. It also proves the gate: it calls `run_interview` before `approve_contact`
and confirms the refusal.
```bash
npm test
```
Runs the test suite: the dial gate, the append-only store and its tombstones,
consent detection and the consent gate, the redaction of contact details, the
fallback writer, the host-piece checker with tamper tests, withdrawal, the
server-side writer behind the citation gate (with a fake writer and a canned piece
carrying a quote the subject never said), the hosted entry (bearer token, session
isolation, health check, the approval link, the post-call webhook with signed and
tampered payloads, tenants), the four genres, the ledger, and the partner sidecar.
Nothing in the suite reads a real transcript unless `INTERLOGUE_REAL_DATA_DIR` names
a directory that holds one, nothing in the suite can reach the phone path, and no
test calls a model: the Anthropic writer runs against a fake client.
### Path 3: the hosted flow, one command, no keys
```bash
npm run demo:hosted
```
Starts the hosted entry in-process on a loopback port with a token and an approval
secret minted for the run, then drives it the way a partner's product would, with
the sidecar example over plain JSON-RPC: brief, an approval link issued and nothing
recorded, the gate refusing before the click, the link opened and the button pressed
(by the script here; by the person the operator sent it to in a deployment), the
fixture interview, `write_piece` with the deterministic reporter, the subject's
`quote_check` (the one-time page opened and "These are my words" pressed by the
script; by the subject in a deployment), `derive_assets` with the LinkedIn post
printed, `status` and `ledger`. It prints the piece and exits non-zero if any step
refuses. No call is
placed, no model is called, no key is used. This is the flow to show a partner first.
### Path 2: inside Claude, where Claude writes the piece
Claude Desktop: after `npm install && npm run build` in the cloned folder, quit Claude
Desktop fully (it rewrites its config from memory while running) and run:
```bash
npm run register-desktop
```
It backs up the config, adds or refreshes only the `interlogue` entry (with the env-file
flag when `.env` exists), and leaves every other entry alone. Or add the entry by hand:
```json
{
"mcpServers": {
"interlogue": {
"command": "node",
"args": ["/absolute/path/to/interlogue/dist/src/index.js"]
}
}
}
```
Claude Code: the repo ships a `.mcp.json`, so opening the folder registers the
`interlogue` server after `npm run build`.
Paste the one-sentence brief above into Claude and add: "I approve contacting her at
that number. Use the founder-case-study fixture instead of calling, then write the
piece and show it to me." Claude calls `brief`, `approve_contact`, `run_interview`,
`draft_piece`, writes the piece, and `check_citations` stores it or returns every
failing span with the closest transcript turn.
From a shell, without a host:
```bash
npm run tool -- brief '{"subject_name":"Marisol Teague","subject_phone":"+1-502-555-0142","about":"founder of Ridgeline Provisions, about switching her order entry to Tallyhook, for Tallyhook'"'"'s marketing team"}'
```
```bash
npm run tool -- approve_contact '{"brief_id":"<brief_id>","approved_by":"Your Name","statement":"I approve contacting this person at this number."}'
```
```bash
npm run tool -- run_interview '{"brief_id":"<brief_id>","fixture":"founder-case-study"}'
```
```bash
npm run tool -- draft_piece '{"brief_id":"<brief_id>"}'
```
```bash
npm run tool -- check_citations '{"brief_id":"<brief_id>"}' --markdown-file my-piece.md
```
## The writing contract
`draft_piece` hands the writer a contract and `check_citations` enforces the parts it
can enforce mechanically:
- Order: H1 headline, story as prose, then `## Pull quotes`. The per-question view is appended by the checker from the transcript.
- Every quoted span is verbatim from a subject turn and is followed by its `(MM:SS)`. A span without a timestamp, or a timestamp without a span, fails. One ellipsis is allowed inside a single turn, in order, with fragments of at least three words.
- No quotes in the dek or the byline: they would sit above the fold with no timestamp.
- Connective prose may frame and sequence but may not assert a fact about the subject outside a cited quote. A number outside a quote fails.
- The angle chooses emphasis, never words. More than one sentence starting with "Asked", or "said:" more than once, fails.
- Three to seven pull quotes. Plain, reported voice.
- A thin interview must say so in the headline; `draft_piece` grades the interview and the packet says when that clause applies.
## Hosted deploy on your own keys
`src/remote.ts` serves the same tools over the MCP Streamable HTTP transport, for a
partner who wants to run InterLogue on their own infrastructure with their own
ElevenLabs account.
- Endpoint: `POST`, `GET` and `DELETE` on `/mcp`. Every request must carry
`Authorization: Bearer <INTERLOGUE_TOKEN>`. A missing or wrong token, or a server
with no token set, is refused with `401` before the request body is read.
- `/healthz` answers `GET` without a token with liveness only: `{ ok, version, token_configured, sessions }`. With a tenant's bearer token it adds that tenant's own `tools`, `phone_tools`, `webhook_configured` and `approval_links`. No subject data, no list of tenants.
- `/approve/<token>`: the approval page. Over HTTP `approve_contact` records nothing
and returns this link for a person to open; the page shows the subject's name, the
masked number, the client and the approver with one button, "I approve this call".
The click records the approval and the dial gate accepts only such a record. The
token is an HMAC keyed with `INTERLOGUE_APPROVAL_SECRET` (never the bearer token, so
a caller holding the token cannot mint a link; without the secret no link is issued);
the URL carries only ids, an expiry and a nonce, nothing about the person; it
expires after 24 hours (`INTERLOGUE_APPROVAL_TTL_SECS`, capped at a week), works
once, is replaced by a newer link for the same brief, and a tampered, stale or
superseded link records nothing. The signature is checked before anything is read
from disk. `INTERLOGUE_PUBLIC_URL` sets the host in the link. With
`INTERLOGUE_APPROVAL_DELIVERY_URL` set, the link is posted there (signed with
`INTERLOGUE_APPROVAL_DELIVERY_SECRET`, last four digits of the number only) instead
of being returned to the caller, which then learns only that it was sent: the
operator's own channel is what hands the link to a person, and the calling program
never holds it.
- `/webhooks/elevenlabs`: the ElevenLabs post-call webhook, verified with
`ELEVENLABS_WEBHOOK_SECRET` against the raw body (`ElevenLabs-Signature:
t=<seconds>,v0=<HMAC-SHA256 over "<timestamp>.<body>">`, 30 minute tolerance). A
verified transcript is stored through the same path as `fetch_transcript`, consent
check included; polling stays as the fallback.
- Several operators on one server: `INTERLOGUE_TOKENS="id:token,id2:token2"` with
per-tenant `INTERLOGUE_<ID>_*` variables for the data root, the public URL, the
ElevenLabs keys and webhook secret, the Anthropic key and writer model, the
approval secret and the link lifetime. Each tenant's sessions, data and keys are
its own; data roots must be distinct and must not nest, or the server refuses to
start; the webhook is `/webhooks/elevenlabs/<id>`. `INTEGRATION.md` lists the
variables.
- The server-side writer: with `ANTHROPIC_API_KEY` set, `write_piece` writes with
Claude (`INTERLOGUE_WRITER_MODEL`, default `claude-sonnet-5`); without it, with the
deterministic reporter. Either way the piece goes through `check_citations`'s check
and is stored only on a clean pass.
- One data directory per MCP session under `INTERLOGUE_DATA_ROOT` (default
`data/sessions`). Two clients of the same deployment never see each other's briefs.
A session's directory stays on disk when the session ends, so withdrawal can still
find it.
- The phone tools (`place_call`, `fetch_transcript`) are registered only when
`ELEVENLABS_API_KEY`, `ELEVENLABS_AGENT_ID` and `ELEVENLABS_PHONE_NUMBER_ID` are all
set. Without them the deployment is text-only and cannot dial.
- `PORT` defaults to 3000. `INTERLOGUE_ALLOW_FALLBACK=1` adds the deterministic writer.
Run it locally:
```bash
npm run build && INTERLOGUE_TOKEN=$(openssl rand -hex 24) npm run start:remote
```
Docker (Node 20, `npm ci`, `npm run build`, runs as a non-root user, copies no `.env`):
```bash
docker build -t interlogue . && docker run -p 3000:3000 -e INTERLOGUE_TOKEN=<secret> -v interlogue-data:/data interlogue
```
`render.yaml` is a Render blueprint for the same image. Every secret in it is declared
unset (`sync: false`) and is entered in the Render dashboard; `autoDeploy` is off.
Nothing in the repo deploys anything. The Dockerfile was written and read, not built,
during the run that added it; the first build is on whoever deploys it.
A client connects with the SDK's `StreamableHTTPClientTransport` and a
`requestInit.headers.authorization` of `Bearer <token>`, or with plain JSON-RPC over
HTTP as `examples/sidecar/sidecar.ts` does. `INTEGRATION.md` has the request and
response shapes, the approval and webhook flows, and the tenant variables;
`OPERATIONS.md` has what to set, what to watch, how to rotate a secret and how to
handle a withdrawal.
## Withdrawal
The consent script promises a subject can ask afterwards to have their interview
thrown out. `withdraw_subject` honours that:
1. Called with a `brief_id`, or with a `subject_name` and `phone` that match exactly
one approval record (two matches refuse as ambiguous, none refuse), it previews
what would be removed and returns a `confirm_token`. Nothing is removed.
2. Called again with `confirm: true` and that token, it replaces the brief, the
approval, the call record, the transcript and every piece with a tombstone that
holds only the id, the time and the reason `"withdrawn"`. A tombstone is never
overwritten by a later save, and every loader treats it as withdrawn: the dial
gate refuses, `status` reports it, `draft_piece` has nothing to draft from.
3. Two audit lines go to `data/audit/withdrawals.jsonl`, the first before any record
is touched. They carry ids and external references only: no name, number or text.
4. The result names what the operator must delete by hand: the ElevenLabs
conversation id (audio and their transcript copy) and the Twilio call SID (the
call log). The tool never calls either service.
Withdrawal is the one sanctioned exception to the append-only transcript rule, and
it removes rather than edits.
## Redaction of contact details
The stored transcript is verbatim. Everything derived from it for display is masked:
a phone number becomes `[phone number removed]`, an email address `[email removed]`,
a street address `[address removed]`. That covers the reporter's packet (quote
candidates and the full transcript), the per-question view of a piece, the story and
pull quotes of a stored piece, the citation report, the opening lines
`fetch_transcript` shows, and the hints in a failing check. The matchers are written
for speech: "five oh two, five five five, oh one four two" and "marisol at ridgeline
dot com" count; year ranges, money and business figures do not.
The citation check runs against the verbatim turn first. A quote that wholly contains
a contact detail resolves and is stored masked. A quote that cuts through one fails. A
quote copied from the masked packet, mask token included, resolves against the masked
rendering of the turn. After a clean check the piece is masked and a leak check
confirms no cited contact detail survives; if one did, the check is marked failed and
nothing is stored.
## Where data lives
Everything at runtime goes under `data/`, which is gitignored, so subject names and
numbers never enter git. The hosted entry uses one such tree per session under
`INTERLOGUE_DATA_ROOT`; the stdio server uses `INTERLOGUE_DATA_DIR` or `data/`.
```
data/briefs/<brief_id>.json name, phone, role, company, client, topic, angle, question plan
data/approvals/<brief_id>.json who approved, when, their statement verbatim; earlier approvals under "replaced"
data/transcripts/<brief_id>.json append-only; turns with time_in_call_secs and MM:SS; consent evidence
data/pieces/<brief_id>_pc_<id>.json story, pull quotes, per-question view, citation check, markdown (contact details masked)
data/calls/<brief_id>.json conversation id, call SID, timing, cost, last four digits only
data/audit/withdrawals.jsonl withdrawal events: ids and external references only
```
A withdrawn record is `{ "id", "withdrawn_at", "reason": "withdrawn" }` at the same
path. The fixture under `fixtures/` is fictional and committed; every name, company
and number in it is invented, and the 555-01XX range is reserved for fiction.
## The quote check: the subject's word on their words
A piece attributes quotes to a person. `quote_check` shows that person those quotes,
each with the minute in the call it was said, and records one answer per piece:
confirmed, or disputed with the quote numbers and a note of up to 500 characters.
Hosted, the page at `/quotes/<token>` is signed with the approval secret, verified
before any disk is read, answers once, expires with the approval lifetime, and shows
no phone number; with a delivery channel the link goes to the operator's endpoint as
a `quote_check` payload and the calling program never holds it. Over stdio the tool
returns the message to send and a person records the answer with `relayed_by`. A
disputed piece blocks `derive_assets`; the fix is a new piece from the same
transcript without the disputed quotes. The page and the message both tell the
subject that the whole interview can be withdrawn on request, which is what
`withdraw_subject` does. Nothing publishes on its own either way: the check is a
record for the human review, not a gate the server enforces.
## Security: what the approval record is and is not
The approval record is the only thing that unlocks a dial, so it is worth being exact
about what it proves.
It **is**: a file under `data/approvals/` that says a named person (`approved_by`)
approved contacting this brief's subject at this brief's number, with the statement
they made stored verbatim, and the time. `place_call` and `run_interview` both call
the same `assertDialApproved` in `src/gate/dialGate.ts`, which requires that file to
exist, not be a tombstone, and name the brief's exact subject and number. A name or
number that differs from the brief is refused at `approve_contact`, so the record can
only ever cover the person on the brief. Replacing an approval keeps the earlier ones
inside the record.
It **is not**, over stdio: an identity check, a login, or proof that a human was in
the loop. There is no user authentication. `approved_by` and `statement` are whatever
the host passes, and the tool descriptions tell the host that a user request which
names the person and asks to contact them already counts as the approval. In a Claude
session the human is the one typing that request; in any other host the operator has
to make sure that is still true.
Over HTTP the hosted entry supplies the human-only surface: `approve_contact` records
nothing and returns a signed one-time link; a person opens it, sees the name, the
masked number, the client and the approver, and clicks "I approve this call"; the
record that click makes (`approved_via: "click"`) is the only kind the dial gate
accepts there. Be exact about what that proves: that whoever held the link pressed
the button. The calling program receives the link from `approve_contact` in order to
hand it to a person, so it could press the button itself; the server cannot tell a
browser from a program. The link is signed with a secret the caller does not hold,
so it cannot mint links, but it can use the one it was given. The human step is therefore the operator's delivery of the link to a person and nobody else. A
deployment that sets `INTERLOGUE_APPROVAL_DELIVERY_URL` closes that gap: the server
posts the link to the operator's own channel and the calling program never receives
it, so the click can only come from someone that channel reached.
Other facts a partner should rely on, each verified by a test:
- Credentials are read from `process.env` only, never from a file by this code, never
printed, and the ElevenLabs base URL is fixed so no environment value can redirect a
request carrying the key.
- Nothing reaches `placeOutboundCall` except `place_call`, after the gate. The test
suite deletes every `ELEVENLABS_*` variable before the tools load and fails on any
network call.
- Transcripts are append-only. A piece is regenerated from its transcript, never the
other way round. Withdrawal replaces, it never edits.
- A subject's explicit refusal to be recorded stores nothing. A missing AI disclosure
or recording ask is stored with a notice; the disclosure and the ask themselves live
in the ElevenLabs agent's configuration, outside this repository.
- The elision rule allows one ellipsis inside a single turn with fragments in order;
the checker cannot see what was dropped between them. A reviewer should read
elided quotes against the transcript.
- A transcript can come from the fixture or from turns the host supplies, and the
check confirms quotes against whatever was stored. The footer of every piece names
its source so a reader knows whether a recorded call stands behind it.
## The phone path
The phone path places a real call through an ElevenLabs agent over the native Twilio
integration. It is two tools, because a live call outlasts one MCP tool call:
1. `place_call` with `brief_id`. Refused unless the dial gate finds the approval for
the brief's exact name and number. It triggers the outbound call, passing six
dynamic variables built from the brief (`subject_name`, `subject_role`,
`client_name`, `genre`, `angle`, `question_plan`), records the conversation id
under `data/calls/`, and returns.
2. `fetch_transcript` with `brief_id`. Waits for the call to end, fetches the
conversation, normalizes it to one timestamped turn per entry, runs the consent
check on the real opening, and stores the transcript append-only. A refusal stores
nothing. A voicemail greeting is `NO ANSWER`. Each call waits up to about three
minutes and answers `STILL IN PROGRESS` if the interview is still running; a
ten-minute interview needs about four calls. If a host cancels a wait early, the
next call for that brief stays under that limit.
Credentials for a local stdio run go in `app/.env` and are loaded by Node's own flag:
```
ELEVENLABS_API_KEY=...
ELEVENLABS_AGENT_ID=...
ELEVENLABS_PHONE_NUMBER_ID=...
```
```bash
npm run start:phone
```
That runs `node --env-file=.env dist/src/index.js`. For Claude Desktop, use the same
two arguments with absolute paths. The agent itself is configured in ElevenLabs: its
first message must state that it is an AI and ask permission to record, its prompt
reads the six dynamic variables, and the Twilio number is imported under Phone
Numbers. Endpoints used: `POST /v1/convai/twilio/outbound-call` and
`GET /v1/convai/conversations/{id}`, authenticated with the `xi-api-key` header.
## Layout
```
src/index.ts MCP server entry, stdio
src/remote.ts the same tools over Streamable HTTP, bearer token, one data dir per session, tenants, approval page, webhook
src/hosted.ts hosted-mode context and the signed approval token
src/writer/ the server-side writers: interface, deterministic reporter, Anthropic API writer
src/phone/ingest.ts one path from a finished conversation to a stored transcript (polling and webhook)
src/phone/webhook.ts ElevenLabs post-call webhook signature and payload
examples/sidecar/sidecar.ts a partner's sidecar over plain JSON-RPC, no SDK
src/tools/*.ts one tool per file
src/gate/dialGate.ts the only unlock for a dial
src/store/fileStore.ts JSON persistence under data/, tombstones, audit lines
src/withdraw.ts withdrawal: plan, preview token, execute
src/consent.ts AI disclosure, recording ask, refusal and voicemail detection
src/generate/packet.ts the reporter's packet for the host writer
src/generate/contract.ts the writing contract
src/generate/markdownPiece.ts the host-piece checker and assembler
src/generate/citations.ts the citation rule and re-check
src/generate/redact.ts contact-detail matchers and masking
src/generate/maskPiece.ts masking a checked piece, leak check
src/generate/reporter.ts the deterministic fallback writer
src/questionBank.ts question banks and angle weighting
src/phone/elevenlabs.ts ElevenLabs REST client (outbound call, conversation fetch)
src/phone/normalize.ts brief -> dynamic variables; conversation -> timestamped turns
fixtures/ the fictional text-only fixture
scripts/run-spine.ts end-to-end fallback runner over stdio
scripts/demo-hosted.ts the hosted flow in one command over loopback HTTP, no keys
scripts/call-tool.ts generic one-tool client over stdio
scripts/register-desktop.ts writes the Claude Desktop config entry
test/ node:test suite
Dockerfile, render.yaml the hosted entry's image and blueprint
CHANGELOG.md, INTEGRATION.md history, and the contract for another product
OPERATIONS.md, SECURITY.md running the hosted entry; what is enforced and what is asked for
```
TDQS
Scored across 12 tools
Tools mostly map to distinct steps in a linear interview spine, but there is a potential confusion between the simulated text path (run_interview) and the real phone path (place_call + fetch_transcript), and between the fallback writer (generate_piece) and the preferred writer flow (draft_piece + check_citations). Fortunately, the descriptions explicitly clarify these boundaries, which mitigates the overlap.
The names are mostly snake_case verb_noun (e.g., approve_contact, run_interview, fetch_transcript, check_citations). A few names are nouns or noun phrases (brief, question_templates, status), which is a minor deviation but still readable and consistent with the domain.
With 12 tools, the count is not unreasonable, but three stubs (question_templates, download_recording, discover_contacts) are explicitly cut-list placeholders that add no real functionality and dilute the set. The remaining tools feel appropriately scoped for the interview workflow.
The surface covers the full spine from brief to approval to interview to piece generation and citation checking, with status reporting. However, the stubs represent unimplemented features, and there is no tool to update or delete briefs or transcripts, though the workflow may not require them.