Docs

Changelog

RSS

All notable changes to the Amdahl Platform API are recorded here. The format follows Keep a Changelog: each entry groups its changes under the sections that apply (Breaking, Added, Changed, Deprecated, Removed, Fixed, Security) and omits the ones it does not use. Breaking comes first and flags anything that needs action on your side. Dates are the day the change landed on the stable path.

Last updated 2026-08-16

Filter
Added
  • The pipeline gate now tells you WHY, not just which lines failed. verdict.gate (and GET /eval-runs/{id}/gate) carries two new fields: reasoning, the judge's rationale for the copy you submitted, and critique, what would have to change for it to pass — both in the judge's own words. They were computed on every gate-mode run already and reached no caller, so a pipeline holding a send could report that three checks failed and nothing a writer could act on. critique is null on a full run rather than absent, because a full run critiques the version the eval wrote rather than yours; read that null as "not measured on this run", never as "nothing to say". Runs graded before this shipped answer with both fields set to null instead of omitting them, so a consumer can branch on one shape.
  • Filter and group on how many people spoke in a turn. Three columns are now available on the interactions surface in search.fields, search.query, data.query, data.ask and data.aggregate: speaker_ambiguous (does this row hold more than one voice), speaker_count (how many), and turn_speaker_keys (the roster of identity keys, readable via UNNEST but not filterable). A row on this surface is a grouped TURN whose speaker_name names only its FIRST member, so an aggregate keyed on a speaker was folding rows together inside the warehouse where nothing downstream could repair them. WHERE speaker_ambiguous = FALSE scopes such an aggregate to turns whose speaker is verified — 4.88% of stamped rows hold more than one voice (102,992 of 2,110,805, fleet-wide, measured 2026-08-16). Read that denominator literally: rows written before these columns existed carry no verdict at all and are excluded from it, so the share of *every* row in the surface is 4.67%, not 4.88%. Your own workspace will not read either number — per-tenant values range from 0% to 20.9% — so scope the query to your data before quoting a rate. Use COUNT(DISTINCT k) FROM interactions, UNNEST(turn_speaker_keys) AS k to count distinct PEOPLE: COUNT(DISTINCT speaker_name) counts turn stamps and omits anyone who never leads a turn.
  • Eval runs can now grade against the market, not only your own conversations. Pass include_external: true on POST /evals/run (MCP: the same param on the evals tool's run action) and one public-web fan-out runs — the query planned by a lightweight model from your submission — and its snippets join the evidence pool tagged external, each labelled with its publication. The tier is a licence the writer and the judge are both held to: external quotes back market claims ("analysts report"), never customer voice, and citing one for a customer claim is a grounding failure. Opt-in (a paid fan-out), gated on the external_search:execute scope — refused without it rather than silently dropped — and external_cap (default 15, clamped 1–25) bounds the retrieval. The run's evidence disclosure gains external_status, external_query, external_sources and external_quotes, and the progress trail reports the fan-out as external_resolved.
Changed
  • Correction: the speaker mis-attribution rate we published is one workspace's, not the fleet's. The 2026-08-15 entries state 11.84% of members mis-stamped and 13.58% of turns holding more than one voice. Those numbers are correct, and they are workspace a7c1bd65 — which turns out to be the least representative workspace on the fleet for this defect: every one of its multi-voice turns *also* mixes an internal speaker with an external one, the kind a regroup clears, while on every other workspace the reverse holds and almost none of the rate is clearable that way. Measured the same day across nine workspaces, the fleet rate is 7.70% of members mis-stamped (234,473 of 3,044,300) and 6.11% of turns holding more than one voice (134,510 of 2,201,887). The four published entries now carry both figures. Nothing about the fix changed — the behaviour those entries describe is what shipped; only the magnitude was quoted from an atypical sample.
  • And the part of it no regrouping can reach. Of the 134,510 multi-voice turns, only 8,419 (6.3%) mix speaker types at all; the other 126,091 (93.7%) hold two people of the *same* type — two buyers, or two of your own team. Grouping breaks a turn on a speaker-*type* change and never on identity, so those 93.7% are not a backlog that a repair drains: they are what the grain does. That is why the fix is a new shape in the data rather than a tightening of the existing rule.
  • "Does this row hold more than one voice" is now READ from the warehouse instead of being worked out at query time. A row on the interactions surface is a grouped turn — several utterances folded into one row and stamped with its first member's name — and until now this platform answered "did anyone else speak in this turn" by re-walking the turn's members on every read. The pipeline now records the answer on the turn itself when it builds it, and 2,109,611 turns have been stamped (102,963 of them, 4.88%, hold more than one voice). Every read path that verifies speakers — search.query (both lanes), data.query, page templates, blueprint steps, embedded pages, deal health and the testimonials people picker — now takes the recorded answer first. You may see `speaker_ambiguous` change on rows whose members are no longer resolvable: a turn the pipeline counted as one speaker at the time it was built is no longer reported as ambiguous just because one of its utterances has since gone.
  • NEW per-row field `speaker_ambiguous_source`, on every turn-grain row that carries speaker_attribution. "stamped" = the verdict was read from the column the pipeline writes; "overlay" = the pipeline recorded nothing for that turn, so it was reconstructed at read time. A "stamped" row also carries `speaker_count`, the number of distinct speakers the pipeline counted. Both are additive — nothing was renamed and no existing field changed shape.
  • A turn with no recorded verdict is NOT reported as single-speaker. This is the distinction the whole design turns on: an absent value means *not measured*, never *one speaker*. Interactions excluded from your workspace's analysis are never stamped by design, so an unrecorded turn is a permanent state for part of the corpus, not a gap that closes. Those rows keep the read-time reconstruction and say so in speaker_ambiguous_source.
  • NEW envelope counters, so you can see which path answered. corpus.speaker_stamped_rows and corpus.speaker_fallback_rows split the rows on both search lanes; the speaker_grain block adds multiplicity_source ("stamped" / "overlay" / "mixed"), stamped_rows, fallback_rows, disagreed_rows (rows where the recorded verdict and the reconstruction differed — the recorded one is served) and stamped_columns_available. Deal health's speaker_grain and the testimonials picker's speaker_grain (stamped_turns / fallback_turns) carry the same split. Emitted whenever verification ran, including when every row took the same path — a counter that only appears on the interesting value cannot be watched.
  • Nothing was removed. The read-time reconstruction is still there and still runs; it is now the fallback rather than the primary answer. It is also still the only thing that can NAME a speaker — the recorded columns count voices, they do not identify them — so re-attributing a mis-stamped row, the turn_speakers roster and the people picker all continue to work exactly as before. *(Correction, 2026-08-16: "they do not identify them" is too strong. turn_speaker_keys carries e:<email> and n:<name> keys, and an email identifies a person as well as an email ever does; what no key carries is a display name — n: keys are lowercased, and 50.6% of multi-voice key slots are e: with no name in the row at all. The practical consequence in this entry is unchanged: you still cannot render who spoke from the recorded columns alone. The sentence understated what the keys are, not what they can do for you.)*
  • `data.aggregate` now discloses when a number was computed from turn stamps. Results already carried a speaker_grain envelope when a speaker column appeared in the returned rows. They now also carry one when the speaker column reached the SQL only through a metric or a filter — for example count_distinct of speaker_name grouped by company, whose rows contain no speaker column at all and so previously came back with nothing saying a speaker was involved. The numbers are unchanged; only the disclosure is new.
  • The "could not verify the speaker" notice now names a remedy an aggregate can use. It previously advised selecting interaction_id alongside the speaker columns, which a GROUP BY cannot do without changing its grain. It now points at WHERE speaker_ambiguous = FALSE and states what that scope costs: it turns a wrong name into a missing person, because anyone who never leads a turn drops out of the result.
  • Persona counts in external search are now computed over verified single-speaker turns. inferred_persona is derived from the speaker's title, which on a multi-voice turn belongs to the turn's first member — and because the facet is a ranked top-N, an over-counted persona displaced a real one. The facet reports its own denominator, which is smaller than total_utterances; read shares against that, not against the total.
Removed
  • `enrich` and `lookalike` are retired. The five endpoints they served — POST /enrich/company, POST /enrich/person, POST /enrich/topic, POST /lookalike, POST /lookalike/themes — and the two MCP coarse tools of the same names are gone. The MCP surface is now three tools: search, agents, evals.
  • What replaces them. Similarity over your own corpus is POST /search/query with mode: "semantic" — the same meaning-based ranking, through the door you already use, with the full filter vocabulary available alongside it. Depth on one company, person, or topic is a Chat (POST /chat), which still reaches the market fan-out and returns a written answer rather than a brief envelope.
  • Nothing else on the API moved. search.query / search.fields, Chat, the agent library, Routines and Evals are unchanged, including their scopes and response shapes.
Fixed
  • `POST /search/query` — a whole-corpus aggregate now runs in its bare form. {"mode":"filter","metrics":[{"fn":"count"}]} used to be refused as an empty request while the same aggregate ran once a filter or an unrelated query was added. A group_by with no metric now gets a message naming the missing metric instead of the generic empty-request error.
  • `POST /search/query` — params a lane cannot honour are named in `corpus.ignored_params`. A query sent to the filter lane is not read by that lane, and async / max_subqueries do not apply to the semantic lane; all three are now disclosed rather than dropped silently. Notably, an async: true request whose wording routes it to the semantic lane answers synchronously and says so — previously it returned no job handle with nothing explaining why.
  • `POST /search/query` — broad asks on the async lane no longer come back empty. The async planner ran on the synchronous 5-second budget, so an ask split across many sub-questions was cut while planning and returned no coverage — exactly the case the async lane exists to serve. It now plans on the async budget.
  • `POST /search/query` — an ask naming one competitor no longer returns the whole rival leaderboard. "How many interactions mention the competitor Acme" matched a corpus-wide template that has no place to put the name, so any two named competitors produced identical results presented as a complete answer. Asks that name a specific company, product, or competitor now route to the query writer that can scope to it.
  • `POST /search/query` — questions that fit a single query are answered instead of refused. The query writer declined asks it judged ambiguous, or expected to return nothing (an unfamiliar product name, a date range before your data starts). It now resolves the ambiguity and states which reading it took, and returns an honest zero rather than reporting the question as unanswerable.
  • `GET /search/fields` — `deals.company_ids` is correctly advertised as not filterable. It is an array column, but was listed with the full set of string operators; filtering on it returned a server error. It is still discoverable and still readable in results.
  • `GET /eval-runs/{id}/drafts` — the submitted side no longer shows a prompt you did not send. On a run submitted with only a message, submitted.prompt carried the eval's own suggested reusable prompt. It is now null, per the documented contract. The suggested prompt is unchanged on improved.prompt, and a simulated message on a prompt-only run still appears, still flagged by simulated_before.
  • PUBLIC SURFACE: an embedded page now shows the same attribution the app does — and embed consumers see this without logging in. A row on the interactions surface is a grouped turn — several utterances folded into one row and stamped with its first member's name — so speaker_name, speaker_title, role_level, is_champion, is_economic_buyer and champion_score can be about somebody else. 11.84% of member utterances carry a stamp that is not their own and 13.58% of turns hold more than one voice (measured live 2026-08-15). The page-embed renderer deliberately bypasses the shared SQL gate (that gate derives its access predicate from a session user, and an embed has no session user, so reusing it would fail open) — but the bypass also skipped what the gate does on the way out. In-app a row arrived carrying speaker_attribution saying whether anybody had verified it; through a public embed link the identical row arrived bare. It no longer does: /api/embed/pages/.../render runs the same turn-grain verification, stamps speaker_attribution on every row, and returns a speaker_grain envelope per query. Names on public embeds will change — a two-voice turn now names nobody rather than naming its first member.
  • Embedded pages also stop serving wrapper-shaped cells. The same bypass skipped the gate's cell flattening, so one page served {"value":"abc"} publicly and "abc" in-app. Both are now scalars. This was load-bearing, not cosmetic: an unflattened turn key is unkeyable, so it silently disabled verification too.
  • Page renders carry `speaker_grain` on both paths. The authed page runner was computing the gate's verdict and discarding it, leaving a page holding corrected values with no statement of what was corrected — indistinguishable from a build that never checked. Both runDeclaredQueries and the embed renderer now forward it.
  • The Champion & EB Voice template says which of its numbers are checked. Its two quote tables now select the turn key, so each quote is verified against the atomic utterance record and renders an Attribution column beside the name (utterance = verified, ambiguous = the turn held more than one voice and the name was withheld, turn = an unverified stamp). Its three headline stats and the seniority chart cannot be fixed this way — COUNTIF / AVG / COUNT(DISTINCT) fold the stamps into scalars inside the query, before any row exists — so the page now carries a callout saying those numbers are unverified, on the page itself rather than only in the envelope, because an embed consumer reads the render and never sees an envelope.
  • The testimonials people picker was hiding people, not just miscounting them. It asked BigQuery to GROUP BY speaker_name, so a person who is never the first member of any turn at an account produced no group and did not appear in the picker at all — on an account where two people always talk together, the second one was invisible. It now reads at turn grain, verifies each turn against the atomic utterance table, and aggregates on the verdict. interaction_count counts turns the person was verified to have spoken in, so it goes up for anyone who habitually shares a turn; each person's title comes from their own atomic rows rather than from a window function partitioned on the stamp; and the response carries speaker_grain plus per-person ambiguous_turns / unverified_turns. The read is bounded at 1,000 turns per company — it is row-grain where it used to be a warehouse-side aggregate — so a larger account reports truncated: true and counts over its most recent turns rather than letting a partial count read as a total.
  • Scoped cluster-detail quotes name a verified speaker. When a data-scoped member opens a theme, its representative quotes are re-drawn from the turn-grain view; those quotes now carry speaker_attribution, and a quote from a turn holding two voices ships its text with no name rather than crediting the turn's first member. Slice sizes are unchanged — this was a mislabel, not a miscount.
  • Still not covered, named rather than left silent. Five reads compound a wrong attribution into a number and none of them can be fixed by a read-side overlay, because each groups or filters on a speaker column inside BigQuery, where the turn key no longer exists: the knowledge-bank people matcher (peopleMatchingService), the company-catalog persona facet, the external-search persona_counts aggregate, the listening-lens document author and transcript blob, and the shared deal-attached-voice SQL fragments (whose QUALIFY ROW_NUMBER() OVER (PARTITION BY speaker_name) caps quote diversity on the wrong key across three blueprint starters and four page templates). Each needs its aggregation moved out of SQL, the way deal-health's and the people picker's were. Separately, the sales-digest transcript reads through a remote MCP data tool, so whether it is already covered depends on which build SALES_DIGEST_MCP_URL points at — undetermined, not assumed either way.
  • Quote panels stop hiding the second person in the room. A row on the interactions surface is a grouped turn — several utterances folded into one row and stamped with its first member's name — and eight quote-panel queries (three blueprint starters, four page templates) spliced QUALIFY ROW_NUMBER() OVER (PARTITION BY speaker_name ORDER BY quality_score DESC) <= 3 into their SQL. That cap does not relabel a row, it deletes it, and it ranked inside a partition keyed on the stamp: a turn holding two voices spent the *first* member's slot even when the quotable sentence was the second member's, and anyone who never leads a turn had no partition at all and so could never occupy a panel slot under their own name. 11.84% of member utterances carry a stamp that is not their own and 13.58% of turns hold more than one voice (measured live 2026-08-15). A panel whose entire purpose is speaker diversity was delivering *stamp* diversity, and the difference was invisible from the output. The cap now runs after the gate has verified who spoke, against that verdict — a turn holding two voices charges a slot to every member, so a shared quote cannot be spent twice.
  • Five page-template quote panels were not being attributed at all. They selected no interaction_id, so the turn-grain overlay reported no_turn_key and every name they rendered was an unchecked stamp — the panels looked identical to verified ones. They now select the turn key, so the same verification the in-app and embed paths already run actually reaches them.
  • The bounded-read trade, stated rather than absorbed. The cap used to run inside BigQuery, so a LIMIT 25 returned 25 diverse rows drawn from the whole corpus. It now runs over the rows the read returned, so a panel can come back shorter than its limit. speaker_diversity.pool_exhausted says which of the two happened — the corpus genuinely has few quotes, or the read ran out of candidates and more exist that this query never saw. Only the second is fixable by reading further. Previously the panel was full and capped on the wrong key; now it can be short and says so.
  • A name the corpus contains is no longer reported as absent. The knowledge-bank people matcher asked WHERE LOWER(speaker_name) LIKE … GROUP BY speaker_name on the turn-grain surface, so a person who is never a turn's first member has no row carrying their name, matched nothing, and came back as matches: [] — indistinguishable from a genuinely unknown person. It now reads the atomic utterances table, where the filter key and the grouping key are the speaker's own name, so nobody is hidden and no aggregation has to leave SQL. interaction_count now counts turns the person was verified to have spoken in: it goes up for anyone who shares turns and down for whoever was absorbing their turn-mates' words. If the atomic read fails the old turn-stamp query still answers, and every match it returns is labelled speaker_attribution: 'turn'.
  • Internal lens documents stop inventing an author. ANY_VALUE(speaker_name) AS author picked an arbitrary turn's stamp and presented it as the author of a whole conversation — non-deterministic, and on a five-speaker call it erased four of them before a lens engine ever saw the document. A conversation with more than one speaker now returns no author rather than one chosen by row order. The transcript body is a separate matter and is disclosed, not fixed: its per-line speaker labels are still turn stamps, and every internal document now carries speaker_attribution: 'turn' saying so.
  • Two reads are disclosed rather than corrected, with the reason named. The company-catalog persona facet (GROUP BY speaker_title) omits any title held only by people who never lead a turn, so an audience cohort the tenant genuinely has cannot be selected — but it is an unbounded tenant-wide DISTINCT with no turn list to verify, and moving it to the atomic table would silently change its denominator (unlike its two siblings it carries no interaction_type = 'UTTERANCE' filter, and confirming what it counts today needs a live schema read). The external-search `persona_counts` aggregate cannot be attributed at all: inferred_persona is a speaker-*derived* column that the shipped attribution nulls rather than re-derives, so there is no verdict to aggregate on and turn-grain attribution would replace the distribution with a smaller one plus a remainder — differently wrong, not better. Every option and every bucket from both now carries speaker_attribution: 'turn', stamped on the value rather than announced on the envelope, because values get copied out of envelopes.
  • Still not covered, named rather than left silent. Re-deriving the population turned up reads outside this change's five that compound the same stamp into a number, and none is fixed here: the data.aggregate MCP verb, whose group_by allowlist accepts all nine speaker columns from a model at runtime while the column catalog it reads describes speaker_name as "Name of the speaker" with no turn-grain caveat; the account_tier_intelligence engagement aggregate, whose has_champion gate and execs_engaged count decide which accounts a seller works; the champions / economic_buyers curated search templates, which GROUP BY speaker_name behind a stamp-keyed WHERE; the evals cohort floor, whose COUNT(DISTINCT speaker_email) under-reports the very speaker diversity it exists to certify and fails toward refusing to grade; and the data-governance filter preview, which counts would-be-excluded rows on a turn stamp.
  • The API reference no longer advertises the retired `enrich` and `lookalike` endpoints. Both families were removed from the API, but the reference landing page, the docs index, the tool catalog, the error catalogue and the rate-limit page all still described them as live surface — including bullets pointing at per-endpoint pages that no longer exist. Those pages now describe only what you can actually call: Search (search.query / search.fields), Evals (evals.run and its read surface), and Agents (Chat, the agent library, Routines). The monthly verb-cap families behind quota_exceeded are likewise listed correctly as search and evals. No API behaviour changed — this corrects the documentation only.
Added
  • `GET /agents/usage` — thin workspace rollups of recent agent and routine activity (7-day / 30-day run counts, last run, schedule counts). Read-only under agents:read; not a billing surface (token/cost usage stays on chat usage).
  • MCP API keys list rows now include `request_count` (lifetime requests that validated the key) so Operators can see key activity without a separate metrics product.
  • Connections last-run summary exposes richer stream failure fields when the connector reports them.
  • Evals can now be graded against a named slice of your corpus, not just all of it. A new top-level scope on POST /evals/run (and the MCP evals tool's run action) takes typed filters — { surface, field, op, value }, ANDed, up to 25 — over the same field vocabulary search advertises, so _"grade this draft against what executives at closed-won accounts actually said"_ is one call. Filters may mix the interactions, deals and deal_qualification surfaces in a single scope, which search.query cannot do (it takes one surface per call); the deal-grain half is resolved to the accounts it matches and scopes the quote draw to those. Discover field names, types and admitted operators from GET /search/fields (search_field://list, or the search tool's fields action) — never guess them.
  • A scope changes the evidence, not only the framing. A slice that clears the check has its own utterances retrieved and tagged segment — the tier that licenses _"teams like yours"_ — spread across companies so one talkative account cannot speak for the slice. Filters and the existing inputs.audience COMPOSE rather than race: when both resolve, the cohort predicate is ANDed into the filters before the floors are measured, so the counts, the quotes and the slice's name all describe the same intersection.
  • A slice must clear an evidence floor before it is graded against: 3+ distinct external speakers, 25+ utterances, 2+ distinct companies. A search hands you four rows and you judge them; an eval hands you a grade with the slice's name stapled to it, and four rows reported as "what enterprise champions say" is a real number under a false label. When the check does not pass the run still completes and still grades your prompt and message, and names which of seven things happened (not_provided · invalid · no_evidence · thin_evidence · lookup_failed · no_matching_accounts · too_many_accounts) on a scope_resolved step in the run's progress trail (progress.steps[] on eval_run://<id>), carrying the reason, a written message to show a person, and detail where there is something actionable to say. That step is the only surface the outcome reaches today — it is not on the verdict or the report card, where audience already is. lookup_failed means our check broke, never "you have no data", and deal filters matching 500 accounts or more abstain rather than silently grading a clipped subset under the slice's name. What an abstained run falls back to is your whole corpus UNLESS a resolved inputs.audience is still in play, which is gated on its own path and survives. Pass allow_thin_evidence: true when the narrow cut is the question: the run grades it and the step carries below_floors: true beside the real counts.
  • `scope` is a named dimension of the run fingerprint, so a filtered request and an unfiltered one are different runs under the default reuse: "cached" — otherwise the second would be served the first one's corpus-wide verdict under a slice's name. Filter order is canonicalized there, so re-ordering the same filters does not fork the address. The evidence-pin conflict check compares a different, order-SENSITIVE rendering of the slice, so a pin re-stated in another order is refused rather than reused — the safe direction to be wrong in, but reuse the same filter array across an A/B rather than rebuilding it. It is additive: a run that sends no scope behaves exactly as before.
  • A malformed scope is refused, never silently dropped. An unrecognised key (operator for op, column for field) or more than 25 filters comes back as invalid_argument from the run call itself, before a run id exists. The alternative — stripping what we do not recognise — returns a perfectly well-formed report about a wider slice than you asked for, with nothing in it saying so. A scope that narrows NOTHING is a different case and is accepted: {}, {"filters": []}, {"audience": "all"} and a bare allow_thin_evidence all grade unscoped and all record not_provided, so nothing is hidden and there is nothing to refuse.
  • `department` is not filterable, and never was. It is the axis most GTM teams reach for first, and it does not exist as a column on the warehouse view evals and search both read — no spelling of it works on any surface. Cut on role_level (ic · manager · executive · unknown) instead, which is both reachable and, hand-labelled against production titles, substantially more accurate.
  • Attio is a first-class CRM connector. Connect it with a workspace API key and Amdahl syncs companies, people, deals, notes, tasks, and lists — plus the deal stage history Attio keeps natively, so stage-change timing is exact rather than reconstructed. It lands with the same setup surface the other CRMs have: your stage vocabulary, workspace members, and attribute catalog arrive in seconds at connect time, so you can map stages while the first full sync is still running. Attio has no pipeline object — the deal stage attribute is the pipeline — so the pipelines step reads as "not applicable" rather than failing.
  • Connecting Attio or Granola now checks the credential against the vendor before it reports connected. Both call the provider for real with the key you just pasted and show what they found, so a token scoped to the wrong workspace surfaces at connect time instead of as a source that syncs zero records indefinitely.
  • `deal_match_method` on the interactions search surface — filterable, groupable, and published in search.fields with its full value vocabulary. It answers a question the API previously could not: when deal_stage_status, deal_stage_label and deal_amount come back null, was there genuinely no deal on that company, or did the deal match *decline* to attach one because none was open at the time of the conversation? Those are null and "no_open_deal" respectively, they need opposite handling, and together they cover roughly 61% of linked rows — so a blank deal read as "no pipeline here" was the easiest way to misread the surface. The remaining values name the tier that did attach (crm_association, engagement, contact_role, company_time_bounded, company_heuristic, company_post_close), which also lets you keep post-close conversation out of win/loss analysis.
  • Emails an agent sends you are now formatted, with tables and charts. The body of a notifications.email_member send was rendered as plain text: a markdown table arrived as literal pipes, ## as literal hashes, and **bold** as literal asterisks, so a weekly digest landed as a wall of unbroken prose. Bodies are now markdown — headings, bold and italic, links, nested lists, blockquotes, code, rules and GFM tables all render — plus two data visuals written as fenced blocks of Label: value lines: chart draws a horizontal bar chart sized against the largest value in the block (a ranking), and metrics draws KPI cards where a | +12% caption renders green and | -3 pts red. Both take a title after the fence word. Nothing changes for a caller who keeps sending plain text, and the plain-text arm of every email still carries the same content for clients that prefer it. Line charts, pie charts, sparklines and images are deliberately not supported: mail clients block or strip all of them, and a visual that renders for only some recipients is worse than a table that renders for all of them. Raw HTML in a body is escaped rather than rendered, as before.
  • Agent-authored emails now flag values they could not source. The fact-check engine annotates a claim it cannot tie to evidence, and those annotations reached the inbox as literal <unverified>194</unverified> text — a real integrity signal that read as a rendering bug. A marked value now renders as an amber, dotted-underlined number with a one-line legend beneath the email explaining that it could not be matched to a source during that run; the plain-text arm reads 194 (unverified). The legend appears only when something is actually marked, so an ordinary email gains no extra chrome. Nothing about the signal changed — only whether you can read it.
  • GTM Strategist joins the agent library. A shipped agent (gtm-strategist) that answers go-to-market decisions — who to sell to, how to position, why deals stall — from your own customer conversations, and compares them against what the market says. It reads the corpus before the market, sizes a finding by distinct companies rather than mention count, and closes with the observation that would disprove the call. Available in the agent directory alongside Researcher; dispatch it by slug or run it from chat.
  • industry_normalized and industry_source on the interactions search surface. Group or filter by industry_normalized for a canonical industry bucket instead of industry, which is a raw passthrough of your CRM's and the enrichment provider's different spellings — so a plain GROUP BY industry splits one industry across rows and undercounts it. On a corpus we measured, computer software and COMPUTER_SOFTWARE came back as separate rows for the same industry, and the canonical bucket was 23.5% larger than the biggest raw one. industry_source (crm | enrichment:pdl | enrichment:pdl_bulk | null) tells you which rows your CRM asserted versus which were inferred, so you can weight or filter on provenance. Both are visible in GET /search/fields.
  • You can now ground a draft in a comparable customer, by name. A seller writing to a prospect wanted the message built on the work we did with a similar customer — the ordinary sales move — and the eval refused it: every quote came back CORPUS-tier, so naming that customer was an unlicensed account-specific claim. The grader was right; the input vocabulary was the gap. account was single-valued and did double duty as *who the draft is going to* and *whose evidence to retrieve*. Now account stays the recipient and a new `reference_accounts` input names comparable customers whose quotes are drawn at ACCOUNT tier, tagged to that company. The licence is per company: a quote licenses a claim about that company and no other — including the recipient — so "11x cut onboarding time" is groundable when 11x is a reference account, while a claim about the prospect still needs the prospect's own evidence. Up to 3 names, 2 quotes each; the recipient's own draw shares the account tier's slots; unresolved names are reported individually rather than silently dropped. prompt-and-message-eval moves to eval_version 2.28.0 and scores are not poolable across it — the per-company rules are inlined into every grading prompt, so the judge reasons differently on every run whether or not you pass the new input.
  • `/compare` stops emitting a score delta across an eval-version boundary that invalidates it. overall_score changed meaning at 2.14.0 and verdict changed sides at 2.16.0, and nothing checked either side's version — so a delta spanning one of those was an artifact of the version change rather than a change in your copy. Comparisons now carry a version_boundary block naming the transitions crossed, and delta_withheld_reason gains a sixth value eval_version_boundary. The two crossing kinds behave differently: across a payload-meaning bump only score_delta is withheld and submitted_score_delta survives; across a grading-work bump both sides' numbers came from two different instruments, so attribution is blocked entirely.
  • `truncated` on search actually fires now. It was documented as the cap-hit signal and was false by construction on every lane — the SQL gate rewrote every LIMIT to the requested value, so the field could never be true. Any client using it as a sampling-bias guard was recording nothing. Both lanes now peek one row past your limit, detect the overflow, and slice the extra row off, so row counts are unchanged and the flag means what it says. The row_count == limit heuristic people used instead is now a false positive on a population that lands exactly on your limit — read truncated. One deliberate exception: at limit: 1000, the engine's own maximum, there is no room to ask for a row past it, so a full result always reads truncated: true — there it means "there may be more" rather than "there certainly is".
  • `cached` is on the response envelope. It was fuzzy-only and buried under detail.internal, so on the filter lane there was no way to tell a cached answer from a fresh one and any consistency check was silently meaningless. It now rides the top level on the filter and fuzzy lanes, and is omitted (not false) on a miss. The semantic lane still has no result cache, so it never carries the field at all rather than implying a cache that missed.
  • A gate run no longer fails you on a check your artifact type is exempt from. mode: "gate" told the judge what kind of writing it was reading but never applied the exclusion map the full report applies, so a declared landing_copy gate run was graded on all five rubric lines and reported the excluded line inside dimensions_failed — an actionable-looking failure on a check that was supposed to abstain. Scored denominators are unchanged; only the reporting was wrong.
  • A custom eval's `reliability` declaration is no longer lost on edit. Authoring validation requires it for any grader that reports a magnitude, but the store dropped it on write — so a declaration that passed validation never reached the row, and the next edit failed on a block the author had in fact supplied. It now persists, evals.update can revise it, and all three authoring surfaces advertise the field instead of leaving callers to discover it through a refusal.
  • Every `search.query` answer that reads a store now carries a `corpus` block naming the store that answered, at what grain, over how many rows, and why it was empty. The three lanes read different stores — filter and fuzzy read the warehouse, semantic reads a vector mirror bounded to customer-side (external) speech at ingest, so it searches a strict subset — and nothing on the wire said so, which is a difference people were left to infer from empty results. The block reports store, grain, row_count, whether your filters actually reached the store (filters_applied), whether rows carry full content or the mirror's truncated preview, and empty_reason, which separates an honest no_match from a read_failed. The semantic lane degrades to a theme index — a different row shape — when the vector path is unavailable; that now reads degraded: true with filters_applied: false instead of passing for a normal answer.
  • `audience: "customer_voice"` applies the workspace's canonical customer-voice predicate server-side: it keeps customer, sales-target and unknown-relationship accounts, drops investor / advisor / media / competitor / vendor / partner / reseller / other, and rescues a genuinely deal-attached buyer sitting on a dropped account — the half a hand-written relationship filter usually misses. interactions only. Available on the filter lane, and on semantic with hydrate: true; refused on fuzzy, which writes its own WHERE clause and would overwrite it rather than combine with it.
  • `hydrate: true` on the semantic lane bridges each vector match back to its full warehouse row, so one call can find by meaning and cite by attribute — full content plus company_name, speaker_title, is_champion and the other label columns, none of which the mirror stores. The bridge is call-grain, so a hydrated result is the matched utterance's whole call rather than the single turn; corpus.match_count alongside corpus.row_count shows where the rows went. Capped at 200 distinct calls, reported as corpus.truncated rather than silently sliced.
  • Semantic matches also return `parent_interaction_id`. The two lanes' interaction_id were different things — the semantic lane's is the parent call id, the filter lane's is turn-grain — so joining them by matching field name returned nothing, every time. parent_interaction_id joins to interactions.parent_interaction_id and is the finest join available today; joining at utterance grain needs a pipeline change and is not possible yet.
  • All of this is additive. No existing field changed shape, name, or meaning, and a caller that ignores corpus, audience and hydrate gets exactly the responses it got before.
  • Every `search.query` answer that reads a store now carries `corpus.coverage` — the time range the returned evidence actually covers (max_timestamp, min_timestamp), an age histogram (last_7d / last_30d / last_90d / older, always all four, including zeros), and dated_row_count. It replaces a manual discipline ("ask for the max timestamp before calling anything a trend") that had no enforcement and no input. The sharpest case is the semantic lane, which ranks by similarity and carries no temporal guarantee at all: a workspace whose newest call landed this morning could ask about pricing objections and get its best matches from three months ago, with nothing on the wire saying so.
  • Read `coverage.basis` before any number under it. Coverage is not uniformly derivable — an aggregate carries no event-time column, a truncated result is an arbitrary slice of a larger match set, and the fuzzy lane's SQL is written per call so its projection may contain no timestamp at all. Those report basis: "unavailable" with a basis_reason, and their numbers are nulled or zeroed as unmeasured, never as measured zero. matched_set means the block describes your whole match set; returned_rows means it describes the slice you were given. The event-time field comes from a fixed allowlist (timestamp, occurred_at) rather than a name heuristic, which would otherwise pick up close_date — a projected deal close, not an event time — and report a confident wrong span.
  • `coverage: true` adds `gaps` — windows where the corpus held rows and your result set had none. Deliberately not "windows your matches skip": conversation volume is bursty and empty days are business-normal (one healthy week here carried 276 calls with zero on Saturday, Sunday and Tuesday), so an empty-bucket scan would fire constantly on a healthy workspace. When your filters carry a range on timestamp / occurred_at the gaps are measured across that range, which is the only framing that can see your evidence running out *before* the period you asked about; otherwise they are measured across your matched set's own span. It costs one extra read, which is why it is opt-in. A failed baseline reads as gaps_status: "unavailable", distinct from "ok" with an empty gaps array — a broken measurement never poses as "no gaps found".
  • `empty_reason` now reaches aggregates. It keyed off row_count, and a bare aggregate returns one row containing a zero — so row_count was 1 and the field was structurally unreachable on every aggregate ever run: a count over an empty window came back as {"count": 0} with nothing explaining it. A bare aggregate over an empty population now carries empty_reason: "no_match". Aggregates with a group_by were always fine.
  • All of this is additive. No existing field changed shape, name, or meaning, and a caller that ignores coverage gets exactly the responses it got before.
  • `search` has a new `lexical` mode — exact terms, ranked by how rare they are. The existing semantic mode finds utterances that *mean* something like your query, which is the wrong tool when the exact wording is the point: searching your own marketing language returns whoever used those words, not the buyers reacting to them. mode: "lexical" matches literal terms and scores each hit by how uncommon the term is across your corpus, so a match on a standard, product name, or error string outranks a merely-similar line. Reach for lexical when the wording matters and semantic when the meaning does; auto picks lexical on its own when your query contains a quoted phrase.
  • The lexical lane tells you what it actually searched for. Query terms too common to be informative are dropped before the search, and the reply carries corpus.search_lexemes (what was used) and corpus.dropped_lexemes (what was set aside). This matters for reading a zero: without it, "no customer said this" and "we searched for something narrower than you asked" look identical. Field weighting favours the call title and the topic over raw body text, so a hit in context ranks above an incidental mention.
  • The `lexical` search mode now tells you how relevant each match is, and filters out filler. Every result carries relevance (0–1): how distinguishing the best term it matched is, compared with the most distinguishing term you searched for. Previously the only signal was score, which is unbounded — 1.50 and 8.97 gave you no way to tell a real match from a row that happened to clip one common word. Matches that fall below the bar are removed, and the reply reports corpus.min_relevance (the bar used) and corpus.weak_matches_filtered (how many were removed), so a short result set is never unexplained. When every match was filler the reply says empty_reason: "weak_matches_only" — which means "nothing relevant was said", a different and weaker claim than no_match's "nothing was said".
  • A quoted phrase now scopes the search to what you quoted. Asking did anyone mention "data residency" concerns searches for *data residency*, not for "anyone", "mention" and "concerns" as well. Previously the words around a quoted phrase competed with it on equal footing, so the top result could match only the phrasing of your question rather than its subject.
Changed
  • Self-serve onboarding retired. New workspaces no longer start in the self-serve setup funnel; members of a fresh workspace get a book-a-demo welcome instead, and the Amdahl team runs onboarding. The member-state read (GET /console/onboarding/member-state) can now return next_action: "book_demo" for a fresh workspace on a tenant without the funnel flag.
  • Self-serve workspace creation is now opt-in (default off). POST /workspaces (workspaces.create) returns creation_disabled for non-staff callers unless the deployment sets WORKSPACE_CREATION_ENABLED=true, and GET /workspaces/mine reports can_create_workspace: false accordingly. Platform admins can still create workspaces, so Amdahl provisions new tenants during onboarding. Book a demo to get a workspace; joining an existing workspace (domain self-join, admin add) is unchanged.
  • Member personas now include marketing, revops, sales, and other alongside executive and developer (PUT /console/member/persona). New roles map onto the existing Home vs Explore suggestion lean — they do not invent parallel APIs.
  • Asking the copilot how to set up the Amdahl API now points at Settings → Developer for minting keys, without claiming the agent can create them.
  • Scheduled runs are off: living documents and Routines now run only when you ask. Workflow schedules, Routine schedules, the default living-doc bundle that used to be seeded for a new workspace, and the background sweeps that refreshed or re-ran reports on their own are all switched off. Every existing schedule has been disabled rather than deleted, so nothing you configured was lost and no schedule is left advertising a next run that will never happen. Running things on demand is unchanged — "Run now" in the console, POST /agent-blueprints/:id/run (agents.run_blueprint), POST /routines/:id/run-now, and backtests all behave exactly as before.
  • Every Amdahl email now looks like it came from Amdahl. Sign-in codes, invitations, "you've been added" notices, workspace access requests, domain-join notices and agent digests were each rendered by their own template, so the same product sent six visibly different-looking emails. They now share one shell: the Amdahl logo at the top, the wordmark and a plain line explaining why you received it at the bottom, and the brand palette throughout. The shared shell also brings improvements the older templates never had — a table layout that survives Outlook, dark-mode handling so text is not washed out when your client inverts the message, an inbox preview line, and a button that renders properly in Outlook. Because Outlook blocks remote images by default, the logo ships with alt text and reserved dimensions so a blocked image never shifts the layout, and the wordmark is repeated as text so the branding is there either way. Subjects are now prefixed Amdahl: (applied once, never twice, even if a subject already carries it). One email is deliberately left plain: the short personal welcome note, which is written to read like a message from a person rather than from a template.
  • `buyer_pushback` no longer advertises a precision figure. The schema catalog and the query-strategy guidance used to describe a buyer_pushback = TRUE as measured at 72.5% precision (with 85.3% recall inside the judged candidate set). Both numbers came from a single gold-set adjudication that the pipeline team has since withdrawn — one LLM grading another on one tenant, measured before a 2026-07 promotion pass that supplied roughly four fifths of the TRUE rows now live. The field's precision is unmeasured: no hand-labelled ground truth exists for it. What the catalog states instead is the measurable ceiling — re-running the classifier over rows it had already judged TRUE reproduces only about 54% of them, so a TRUE is a pointer to a quote worth reading, not a certified label. Coverage, tri-state (NULL is not FALSE), inherited-recall and does-not-predict-deal-outcome guidance are unchanged.
  • The account_type_normalized field description (served on search fields / search_field://list and data://schema) now spells out the canonical customer-voice predicate in full, instead of pointing at a source file you cannot open. It also warns that the predicate is a disjunction: run it on data.query or the NL-to-SQL lane, because search.query's typed filters are ANDed together and cannot express it. Filtering an account-relationship band without the deal-attachment half silently drops roughly 18% of the rows it excludes — a genuine buyer can sit on an investor- or advisor-typed account.
  • Clarified in the quickstart and the Search endpoint guide that compiled.sql is the query as you asked for it, not the statement that reached the warehouse: the gate resolves the surface to its fully-qualified table and injects your tenant filter and any data-scope predicate before running it. Read it as a record of intent, not something to paste back verbatim.
  • Documented why an eval run names its failed rubric lines two ways. gate.dimensions_failed on GET /eval-runs/{id}/gate carries each line's full text, because it travels without a reasoning field; report.findings.failed[].dimension on GET /eval-runs/{id}/report carries the stem only, because there it renders as a lead-in to that line's reasoning. Same lines, two renderings — the strings differ on purpose. Both field descriptions now say so.
  • `account_status` now says when the account tier came only from comparables. A run that names reference customers and cannot resolve the recipient — the ordinary first-touch case — reports reference_only instead of the same ok a recipient-backed run reports. account_name always disclosed this in prose (11x (reference customer)), but the status is the field you filter on, so counting runs "grounded in the recipient's own words" over-counted unless you knew to parse a display string. Filter on ok for that population, reference_only for the other. Eval version 2.28.1: no grading work changed and no score moved, so submitted-side deltas stay attributable across the boundary.
  • `GET /eval-runs/{id}/gate` and `.../compare/{other_id}` now publish their response shapes. Both returned real bodies while the API reference showed an empty object, so a generated client had no way to learn that the gate answers status at the root, that gate is nullable and a null is not a fail, or that a compare reports delta_withheld_reason with six typed values when it declines to emit a delta. Reading the absence of score_delta as "no change" is the exact misreading the withholding exists to prevent, and the six reasons need different things from you — so they are now in the spec you can branch on.
  • `GET /eval-runs` / `eval_run://list` is substantially cheaper. The lean list read no longer pulls every jsonb blob off disk to build a summary row: evidence_set and candidate_set are read as the single scalar the wire carries (the pin origin's run id) instead of being fetched whole, and target / as_of are no longer read at all. No field on the lean row changed — this is the query underneath the same response. ?include=full is unchanged and still returns every column.
  • `total` on that same response is now a planner ESTIMATE, not an exact count. An exact count re-scanned every matching row on a read whose page is at most 200. Treat total as approximate for display; page with limit / offset and stop on a short page rather than computing page counts from it.
  • Evals: the attribution tally now says WHY a span was unlicensed. The second-person attribution check reports how often improved copy tells the recipient what *they* said — but it counted two very different failures as one number. second_person_wrong_tier is the real defect: a covering claim backed the sentence with another customer's words. second_person_undeclared is weaker and frequently benign — the writer simply declared no claim for an ordinary sentence. The two buckets partition second_person_unlicensed and both now ride on the improved-message facet. Nothing else changes: the policy is still flag_only, so no copy is marked, no score moves, and a reader who ignores the new keys sees the payload they saw before. Runs graded before this shipped carry second_person_unlicensed without the split — filter on the specific field you are reading rather than on the aggregate, or a rate taken across that line reads low.
  • Evals: account evidence now reads your emails, and both sides of the thread. Account-tier retrieval drew only the customer's words, so on a relationship carried by email between calls — where every recent touch is *yours* — the evidence pool froze at the last thing the customer happened to say, and a draft got graded as though nothing had happened since. Measured on one workspace before the change: 37 of 95 accounts had no visible evidence at all, another 30 were stale by an average of 61 days, and 82% of the workspace's email was unreachable. The reported symptom was a draft opening "last we talked" while two months of follow-up — including a meeting the customer had taken — sat unread in the corpus. Up to 3 of the 10 account slots now go to your team's most recent messages, ranked by recency rather than by relevance to the draft, because "what have we done lately" is a different question from "what did they say about this". Replayed across production runs: 4 of 5 resolvable accounts gained fresher evidence, by an average of 45 days.
  • Evals: an email is now judged by its message, not by the thread it quotes. Quote length was bounded by a band sized against spoken turns (a call turn runs about 200 characters); email runs roughly six times longer, so the ceiling was excluding about a third of inbound customer email on its quoted reply chain rather than on anything the sender wrote. Chains, envelope headers and stray HTML are now stripped before the bound is measured, and the quote you see is the stripped text. Call transcripts have no chain to strip and are unaffected. Quotes also carry a new channel (call / email / meeting) on account- and segment-tier evidence, so a written line and a spoken one are no longer presented identically.
  • The rule that your own words never back a customer claim is unchanged — it is now enforced by labelling instead of exclusion. A quote from your team carries speaker_side: "internal", the grader sees it tagged OURS, and citing it as evidence of what the customer thinks is a grounding failure. It is there so a draft does not repeat what you already sent, contradict it, or write as though it never happened. This is the same treatment corpus quotes have always had.
  • `eval_version` moves to `2.27.0`, and scores must not be pooled across it. The evidence a draft is graded against changed, so this is a grading boundary like any other: filter on eval_version before averaging, trending or thresholding stored overall_score values, or read verdict.headline.submitted.score_15, which is comparable across it. One cost worth stating plainly — customer-side quotes fell from 50 to 35 across the replayed accounts, because the internal slice takes its slots from somewhere.
  • Evals: improved messages stop quote-collaging. The generate writer could paste stacked attributed quotes into the email ("As one exec put it…", "Another team…") because the prompt handed it customer quotes to cite by id and asked for claim records with those ids — without saying the *message body* had to stay in the writer's own voice. That produced the Instantly-competitor failure mode: stunning specificity, unreadable narrative. MESSAGE_VOICE_RULE now rides both rewrite and advisory: paraphrase into your voice, at most one short quoted phrase, grounding stays in improved_message_claims via quote ids, one proof point over three, no manifesto closer.
  • House prose style gains the 2026 register tells (negative parallelism, colon reveals, staccato triplets, participial tack-ons, synonym cycling, cadence uniformity, quote collage). Same constant reaches agents, living docs, and every reader-facing eval prompt.
  • `eval_version` moves to `2.29.0`, and scores must not be pooled across it. The improved message the writer emits changed, so every score that depends on that artifact is a different instrument on either side of the line.
  • Evals: a specific claim can now find the line that backs it. prompt-and-message-eval retrieved from the theme index alone, so the evidence pool held aggregations of what customers keep saying and never the utterances themselves. Two of the five message dimensions — Grounding and Verified specifics — ask whether a concrete claim rests on something a customer actually said, and for any claim more specific than a theme the pool structurally could not contain the answer. The grader then reported the claim as unsupported rather than reporting that it could not check, so a draft citing real, verbatim, in-corpus quotes came back reading as invented. Retrieval now declares both legs, and the utterance leg is the one that answers "did anyone actually say this".
  • What this does not fix, stated plainly. A quote must still carry at least 40 characters to be citable, on every evidence leg. The shortest customer lines — the five-word objection that is the whole point — stay invisible to the grader whatever you scope the run to. That is a separate defect being fixed separately; a run that still cannot find a short quote is hitting the floor, not this.
  • `eval_version` moves to `2.30.0`, and scores must not be pooled across it. The evidence a draft is graded against changed on every run of this eval, scoped or not, so this is a grading boundary like any other: filter on eval_version before averaging, trending or thresholding stored overall_score values. The rubric, the bar, the judge and its blinding are all unchanged — what moved is which quotes the judge was holding. Grounded copy should score higher now, and that is a prediction rather than a measurement.
  • Correction: `grouped_utterance_id` is not a durable key. The turn-grain grouped_utterance_id column on the interactions surface was documented as a "stable id" when it was added (2026-07-30). It is stable *within* a single read, but it is not durable across a pipeline regroup — regrouping re-mints the id for any turn whose group boundaries change. Its schema description (visible via data.explore / schema discovery) now says so. Join on it freely; if you have persisted it as a long-lived key in your own systems, re-derive it at read time, and use parent_interaction_id when you need an id that survives regrouping.
  • `search` hydrate — the hydrate description on the MCP search tool said it bridges at call grain. It has bridged at turn grain (one row per match, containing the matched utterance) since the turn bridge shipped; the description is now correct and points at corpus.hydrated_grain for the degraded case.
  • Attribution caveat on hydrated rows — a hydrated row is a grouped turn, and its speaker/stakeholder fields (speaker_title, role_level, is_champion, is_economic_buyer) carry the turn's first member's identity, so they name a different person on 2.3% to 13.5% of external utterances depending on workspace. Each rate is only as good as its name coverage, so both are quoted together: 2.3% at 83.3% coverage, 5.0% at 100%, 5.7% at 91.6%, 6.3% at 100%, 6.1% at 99.2%, and 13.5% at 62.8% coverage — that last one is 21.5% among the rows where both sides are actually named, its published shape biased downward by the 37.2% that are unnamed on both sides and score as a match. Two workspaces have zero name coverage and are UNCHECKED, which is not the same as 0%. Measured 2026-08-15 (amdahl-pipelines#806). This supersedes the 5.0–20.7% band published 2026-08-11, which is kept as dated history rather than deleted: #806 recovered that measurement's unrecorded predicate by reconstruction and reproduced the band to the digit, so the two dates are genuinely comparable.
  • A hit is therefore reliably quotable but not reliably attributable. The caveat now ships on the search.query schema, the MCP search tool, and the data://schema field catalog for the three affected columns, with guidance to read speaker fields from the atomic utterances source instead. The mis-attribution itself is unchanged by this entry — this documents the behaviour, it does not alter it.
  • A workspace reporting zero mixed-speaker groups is still affected. Grouping breaks a turn only when the speaker *type* changes (your team vs theirs), never when the person changes, so two different people on the customer side never split a turn. One workspace reports zero mixed groups and still mis-attributes 2.3%. The fall from 16.2% to 6.1% in another is unexplained — its mixed-group count did not move — and is not evidence of a repair.
  • `search` hydrate — the completeness check it taught could not bear the weight. The hydrate description told callers to compare corpus.match_count against corpus.row_count. That delta does not measure what it reads as: the semantic lane's RPC takes a limit and applies no similarity threshold, so match_count is how many nearest neighbours were pulled and tracks limit, not how many things genuinely matched. The description now names the fields that do answer it — corpus.unresolved_matches (matches with no turn) and corpus.collapsed_matches (matches sharing one turn) — says to read them directly instead of inferring a delta, and states plainly that match_count is bounded by limit.
  • `corpus.hydrated_grain: "call"` is the FAILURE branch, and now reads as one. It means the turn lookup failed and the returned rows are arbitrary turns from the right conversations — context, not your matches (also flagged corpus.degraded). Both the MCP search tool and the search.query schema now say to read hydrated_grain first.
  • Corrected the inferred_persona, role_level, industry and company_segment field descriptions in the search catalog, which agents and the NL-to-SQL generator read to decide how to query. inferred_persona described itself as inferred from conversation content and better populated than speaker_title; it is derived from the CRM job title, so it is neither — treating the two as agreeing is the same fact counted twice. role_level is derived from the same title rather than complementing it, and both now separate "not scored" (null) from "scored, no rule matched" (unknown), which is the modal value and will dominate a persona mix if you leave it in. company_segment now states that it is unpopulated rather than implying a usable CRM segment.
  • `deal_match_method` now advertises every post-close tier, and the guidance names the suffix rather than one value. The pipeline began tagging additional attach tiers as post-close (crm_association_post_close, engagement_post_close, contact_role_post_close) alongside the existing company_post_close. Those values were reaching the warehouse without appearing in the field's published vocabulary, so a filter on one of them matched nothing and read as an absence in your data rather than a gap in ours. All are now listed. The accompanying warning also changed shape: exclude every value ending `_post_close` from win/loss, conversion and outcome analysis — that is four values, and a filter naming only company_post_close leaks the other three while looking correct.
  • New workspaces start in onboarding. A newly-created workspace is now born with the self_serve_onboarding flag ON, so its members land in the connect-your-data funnel instead of the book-a-demo state. Existing workspaces are unchanged, and an operator can still turn it off per workspace.
  • Direct-add emails no longer mention a password. Adding a member by email sends a "you've been added, sign in" notice pointing at the login page, where sign-in is the usual one-time code. The previous new-user mail asked people to set a password and linked to a reset flow that does not exist in this product; worse, that mail was only sent if a recovery link could be minted, so a failure there created the account and sent nothing at all. The notice now always sends.
  • Platform admins can seat someone who has never signed in. POST /api/admin/workspaces/:id/members accepts create_if_missing: true, which provisions the account for an unknown email (and emails them) rather than refusing with user_not_found.
Fixed
  • The evals MCP tool now documents every input prompt-and-message-eval accepts. reference_accounts (the companies your copy cites as proof) and artifact_type were always forwarded but never advertised, so a caller reading the tool surface could not discover them.
  • Eval runs no longer fail with report_unavailable when the judge returns a well-formed response carrying no scored dimensions. Both graders now treat that as a bad sample and re-draw, which is what the existing retry was always for.
  • When an eval run needs scoping, it now names reference_accounts alongside account if your copy cites several companies as proof. Previously it could only ever say account, which is the company the message is going *to* — a different question, and the wrong field for copy built on named customers.
  • A banned phrase in quotation marks no longer fails the hygiene check. A message that quotes a word to disown it ("we never say 'revolutionary'") was being flagged for using it; that check now abstains rather than asserting a violation it cannot tell apart from a mention.
  • `eval_version` moves to `2.31.0`, and rule-grader scores must not be pooled across it. A hygiene check that failed on a quoted mention now abstains, so the grader's pass flag and its fraction can move upward on those runs. The phrase list, the rubric, the bar, the judge and retrieval are all unchanged.
  • The reference_accounts hint now finds companies named anywhere in your copy, including in long documents and regardless of capitalisation. It previously guessed at capitalised words near the top of the text, so a one-pager citing customers throughout got a hint built from its own title block, and names like 11x and a16z were invisible to it. No score changes — the hint is reported, never used to retrieve.
  • Connections no longer allow duplicate workspace integrations. A workspace holds at most one active connection per workspace-owned provider (your CRM, call recorder, Slack, etc.); a second connect of one that is already connected is rejected with a clear conflict, and reconnecting an existing connection still works as before. Personal mailboxes (Gmail / Outlook) and tracked social accounts (X / LinkedIn) stay multi-instance.
  • `GET /connections` now returns only active connections by default (connected/syncing or ever-synced), hiding disconnected and never-synced sources. Pass ?include_inactive=true to include them.
  • Abandoned and dead connector rows (never synced, no stored credential) are cleaned up, so they no longer linger on the Connections list.
  • `data.status` now resolves on every eval read. Submitting a run answered at data.status, but reading the run back nested it at data.run.status and the report read nested it again — so a hand-rolled poller written against the submit shape matched undefined forever instead of erroring. Both reads now mirror status at the ROOT alongside the existing nested field, which is unchanged, so this is purely additive. GET /eval-runs/{id}/report also publishes a real response schema instead of an empty {}. The terminal success value is complete — never completed.
  • The eval `mode` default is documented correctly again: it is `rewrite`, not `advisory`, and `gate` is a real third mode. The default moved to rewrite in 2.22.0 but three published surfaces still said advisory — the evals.run operation description, the MCP evals tool's run action, and the OpenAPI spec generated from the first of those. A caller who took the docs at face value expected anchored suggestions against their own copy and got a full replacement, and gate (grade only what you sent and stop) was undiscoverable from the API reference.
  • The MCP server instructions no longer advertise a `search` action that does not exist. The standalone search.run fast lane was retired — its warehouse behaviour is search.query mode fuzzy verbatim — but the connected-client instructions still routed quick lookups to search { action: run }, described a mode: blended web+news leg, and undercounted the tool set as four. A connected model following that text hit an invalid_argument at the moment it tried to act. The routing table now points at search { action: query }, the section documents the real lanes plus the async / job_id / retry_guidance loop, and evals is listed alongside the other four tools.
  • `X-Request-Id` is now echoed on every response, and browsers can send and read it. The API reference promised an X-Correlation-Id header that has never been set anywhere in the server. The id that actually joins your request to our audit trail is the inbound X-Request-Id — so it is now returned on every response (your value when you send one, a minted one when you do not), added to the CORS allowed headers so a browser client can send it, and exposed so it can read it back.
  • Published rate limit corrected to 60 requests per minute, and the 429 body documented as what it really is. The page said 100 in four places while the platform enforces 60 per IP on /api/platform/v1/*, and it printed a JSON error envelope for a response that is actually plain text. Branch on the HTTP status, not the body. The page also now notes the budget is counted per replica.
  • `search.query`'s `limit` default is lane-dependent, and the docs and schema both say so. It is 100 on the filter and semantic lanes but 50 on fuzzy — where an unforced plain-language question usually lands — so a single documented "100" quietly halved the rows on the most common path. The async lane (async: true + job_id polling), the max_subqueries breadth knob (5 synchronous, 12 async), and retry_guidance are now documented too; all three shipped undocumented.
  • The BigQuery theme-index fallback's fields are documented correctly. search.mdx named cluster_id and score, which do not exist on that path; the real fields are source_id, similarity, member_count and interestingness_score. An integration written against the old text got undefined on every fallback row.
  • Both grader-kind lists now show all eight kinds. figure_anchored (re-runs every figure against its own system-of-record query) and structure (checks a report covers every must-cover record and pairs each risk with a next action) ship and are selectable by authored evals, but four places across two pages still said "the six grader kinds".
  • Eval reuse, the noise floor, and `enrich.company`'s inputs are described accurately. Reuse has two paths (an in-flight join at any age, and completed-run reuse inside a 15-minute window), not the one the docs described in two contradictory ways. The published run-to-run noise figure appeared twice on one page with two different values, both from a retired judge; it is now one attributed, dated figure measured on the live judge. And enrich.company accepts domain, name, or both — domain was documented as required.
  • `search` hydrate now attributes the speaker from the utterance that actually matched. A hydrated row is a grouped turn — several atomic utterances folded into one row — and the pipeline stamps that row with its first member's identity. So whenever a turn spanned two voices, speaker_name / speaker_email / speaker_title / speaker_type named the wrong person, silently. Re-measured live on 2026-08-15: 631 of 5,328 member utterances (11.84%) carry a turn-stamped speaker that is not their own, inside the 5.0–20.7% band published on 2026-08-11. [corrected 2026-08-16] That figure is one workspace (a7c1bd65), and it is the least representative on the fleet for this defect: every one of its multi-voice turns *also* mixes internal with external speakers, which a regroup clears — on every other workspace the reverse holds. The fleet rate, measured the same day across nine workspaces, is 7.70% of members mis-stamped (234,473 of 3,044,300) and 6.11% of turns holding more than one voice (134,510 of 2,201,887). Read the fleet figure as the general rate and the workspace figure as one workspace's. The speaker now comes from the atomic utterances source for the sentence that matched, resolved in the same bridge read the turn lookup already performs — no extra round trip.
  • `speaker_attribution` on every hydrated turn-grain row says where the identity came from. "utterance" is verified from the atomic source. "ambiguous" means two matched utterances landed in this row and disagreed on speaker — the speaker fields come back null and nobody is named, because picking one is the defect this replaced. "turn" means the atomic source had no row, so the fields are still the turn's first member and are not verified.
  • `speaker_ambiguous` is a different question, and both can be true at once. It says the row's text spans more than one speaker — 607 of 4,469 turns (13.58%) on the same tenant. [corrected 2026-08-16] That figure is one workspace (a7c1bd65), and it is the least representative on the fleet for this defect: every one of its multi-voice turns *also* mixes internal with external speakers, which a regroup clears — on every other workspace the reverse holds. The fleet rate, measured the same day across nine workspaces, is 7.70% of members mis-stamped (234,473 of 3,044,300) and 6.11% of turns holding more than one voice (134,510 of 2,201,887). Read the fleet figure as the general rate and the workspace figure as one workspace's. A row can be correctly attributed and still contain another person's words, so quoting the whole content as one person is wrong there even when attribution is clean. When it fires, turn_speakers lists everyone in the row. Both fields ship on every turn-grain row including the clean ones: their absence means only that attribution did not run.
  • Stakeholder columns are dropped rather than left describing someone else. role_level, is_champion, is_economic_buyer, champion_score and inferred_persona are derived from the turn's speaker (the deal_stakeholders join is keyed on speaker_email). When a row is re-attributed to a different speaker they are set to null, because a retained value reads as a fact about the person now named in the row.
  • `corpus.speaker_ambiguous_rows` and `corpus.speaker_unattributable_rows` give the rates without scanning the rows. Emitted whenever attribution ran, including as 0.
  • Still not fixed: reading the interactions surface directlydata.query, the search filter lane, the NL→SQL lane — returns the turn stamp exactly as before. This change is scoped to the hydrated semantic path. The field catalog says so on each affected column.
  • `corpus.collapsed_matches` closes the hydration accounting. row_count is normally lower than match_count, and we told you that difference was unresolved_matches. It mostly isn't. Several matched utterances routinely land in the *same* conversational turn and share one row — grouped turns hold 2.88 atomic utterances on average and 78.3% hold more than one, so about a quarter of matches collapsing is ordinary. Measured live: a 200-match search returned 149 rows with unresolved_matches at 0, and nothing on the wire explained the other 51. They were not missing; they shared rows with matches you already had. match_count = row_count + collapsed_matches + unresolved_matches now reconciles.
  • `unresolved_matches` and `collapsed_matches` are emitted whenever `hydrate` ran, including when they are 0. unresolved_matches was previously omitted on zero, which made "nothing was unresolved" indistinguishable from "this build predates the field" — so a real gap read as unaccounted-for. Absence of these fields now means only one thing: hydration did not run.
  • The identity can still leave a remainder when the gated warehouse read itself withheld rows — an audience predicate, an excluded interaction, or your limit binding. truncated and audience_applied tell you when that happened.
  • `hydrate: true` now returns the utterance that actually matched. It bridged at call grain, so a hydrated result was the matched utterance's whole conversation under a row limit — and because those rows come back in no particular order, the matched turn was usually not among them. Measured on five semantic matches at limits 5, 50 and 200: zero of the five matched utterances present, every time. The rows it did return were real turns, from the right calls, carrying real labels, so the output looked correct while being unusable for the one thing hydration exists for. If you built citations on hydrated output, they may quote a sentence unrelated to the match — worth re-checking.
  • The bridge is now turn-grain: one row per match, and that row contains the matched sentence, with full content plus company_name, speaker_title and the rest of the label columns. corpus.row_count is therefore at most corpus.match_count, so comparing them is now meaningful.
  • `corpus.hydrated_grain` tells you which bridge ran. turn is the row-per-match answer. call means the turn lookup itself failed and the rows are arbitrary turns from the right conversations — flagged degraded: true, and to be read as context rather than as the matches. We kept that fallback because returning the conversation beats returning nothing, but it is never silent.
  • `corpus.unresolved_matches` counts matches whose utterance belongs to no grouped turn and therefore has no warehouse row at all — 1% to 12% of external utterances depending on the workspace. Those are reported rather than back-filled with a neighbouring row, so nothing you cite is a substitution. When no match resolves, the response says so and skips the warehouse read entirely instead of falling back.
  • `corpus.truncated` now also fires when your `limit` binds on the returned rows, not only when the 200-id bridge cap does. It was inferred correctly on the filter lane and simply not reproduced on the hydrated path, so a result cut by limit read as complete.
  • source_row_id still does not join the filter lane, and that has not changed: it is an atomic utterance id, one level below the grouped turn interactions is keyed on. The turn id that would join is what hydrate: true resolves for you. We do not resolve it on every semantic call on purpose — it costs an extra warehouse read, and the un-hydrated lane exists to answer from the vector mirror alone in well under a second.
  • Outbound drafts — when the message generator judges a lead unfit for outreach, its refusal is no longer stored as a sendable draft awaiting approval. Previously the writer had no way to decline, so its reasoning ("this recipient is a student with no work history") landed in the message body at review_status: 'pending' — one approval click from being delivered to the person it described. The generator now returns an explicit decision; a declined lead lands review_status: 'skipped' with the reason on rejection_reason, and the lead is disqualified. Skipped drafts never appear in the review queue and cannot be approved or edited into the dispatch queue.
  • A rate limit on `POST /search/query` and `POST /data/query` is now an HTTP `429`, not a generic error inside a `200`/`500` envelope. It carries a Retry-After header with the exact seconds until the window rolls, the rate_limited error code, and details.retry_after_seconds / limit / window_seconds. Previously it surfaced as a flat operation_error, indistinguishable from a malformed query — so a client could not tell "fix your SQL" (retrying is pointless) from "you are going too fast" (retrying is the entire fix), and any retry logic keyed on HTTP status never fired. If you built a workaround that detects the limit by matching the error message, you can now delete it and branch on the status.
  • The per-endpoint query budget is now discoverable. These endpoints carry their own budget of 10 queries per minute per user, underneath the 60/min account budget that GET /me reports — pacing against 60/min was spending a budget of ten. Every response now advertises the real one in X-RateLimit-Limit / -Remaining / -Reset, so you can pace from a successful call instead of discovering the cap by tripping it. The rate-limits guide documents it; it was previously listed as unreleased roadmap.
  • **A query that is malformed *and* rate limited now reports the syntax error, not the throttle.** The rate-limit check ran inside the validation-failure branch, so the real error was discarded and callers backed off and retried the same broken SQL.
  • Warehouse failures on `POST /data/query` are now `500`, not `400`. Every thrown error on that route was reported as 400, so a BigQuery timeout told you your request was bad and suppressed the retry that would have worked. The three cases are now distinct: 400 invalid_sql (change the SQL), 429 rate_limited (wait), 500 query_failed (retry with backoff).
  • `POST /context/ask`, `/context/substrate/query` and `/context/remember` now return `500` for server-side faults instead of reporting everything as 400. Genuine input errors (a bad intent, an over-long text) remain 400.
  • A failed cluster read on `POST /data/clusters/search` now returns `500` instead of a `200` that looked identical to "this workspace has no themes." A workspace with zero clusters, and a query that matched none, are still honest 200s.
  • On these endpoints the X-RateLimit-* headers now describe the per-endpoint budget rather than the account budget — the binding constraint for that call. Same header names and the same Unix-seconds X-RateLimit-Reset format as before, so no client-side change is needed.
  • An agent run that saved its work to the knowledge base no longer reports itself as failed. When an agent finishes a turn it can hand back the id of the deliverable it produced. That id is stored in a column that only accepts *artifact* ids, so an agent whose deliverable was a knowledge-base document — every living-document workflow — handed back a perfectly real id the column could not hold. The database rejected the write, and because that write is the same one that records the run as finished, the whole thing was lost: the run had already done its research, saved its document and sent its email, and was then recorded as an error with a raw database message attached. The id is now checked before it is stored and simply dropped when it does not name an artifact in your workspace, so the run finishes and is recorded as complete. Nothing about the work changes — the document, the email and the run's own summary were always correct — and the id remains visible in the run's tool history either way.
  • `SOC 2` now finds rows that say `SOC2`, and an empty answer never hides a spelling it could not search. Full-text search treats SOC2 and SOC 2 as different words, so a quoted query for one was invisible to every row written the other way — and came back as empty_reason: "no_match", the one empty value that means "nobody said this" rather than "something went wrong". The lexical mode now searches both spellings of any term whose letters and digits can be written apart or together (SOC 2/SOC2, ISO 27001/ISO27001, O365, 2FA), and reports what it did under corpus.spelling_variants: which spellings it searched, how many rows each found, and how many survivors your limit cut. When results come from more than one spelling they are ranked by rank fusion, because the per-row score of one spelling is not comparable with the other's. If a spelling could not be searched at all, the reply says empty_reason: "spelling_variants_unsearched" instead of no_match — so an empty answer can always be told apart from a mangled query.
  • A curly-quoted phrase now scopes the search, the same as a straight-quoted one. did anyone mention “data residency” concerns searched the whole sentence, letting "anyone", "mention" and "concerns" compete with the phrase you actually quoted — the failure straight quotes were fixed for. Anything pasted from a document editor with smart quotes turned on was affected, and it failed by returning plausible-looking wrong rows rather than an obvious empty.
  • Semantic search was silently returning a fraction of your matches, and small workspaces had it worst. The vector index is shared across workspaces and does not carry the workspace key, so the similarity walk ranked across everything and your workspace filtered the survivors afterwards — at the database default a workspace holding 2.5% of the index recovered a mean of 6.5 of 10 rows against the true nearest neighbours, and 1 of 10 in the worst case. It never over-returned and the top hit was always right, so nothing about it looked wrong. Reported by a caller whose objection theme came back with zero hits and demonstrably exists in the corpus. The search now widens its candidate pool per query: 9.75 of 10 against exact, measured on the same workspace. HNSW_EF_SEARCH tunes it without a deploy.
  • This is a mitigation, not a cure. The durable fix is partitioning the index per workspace so each one searches its own graph; that is not in this release. Until it lands, treat a thin semantic result as thin retrieval, not as evidence your corpus lacks something — mode: "filter" is the lane that answers absence questions.
  • `search.fields` was advertising incomplete value lists for six coded fields, and none at all for `pushback_type`. A filter on a value that does not exist returns zero rows, which is indistinguishable from a value that exists and matches nothing — so an incomplete list reads as a complete one right up until it quietly costs you results. Corrected against production: pushback_type now published (10 values; build_vs_buy and incumbent were unreachable before), speaker_type gains unknown (131,505 rows), record_type gains task and event, interaction_purpose gains internal and unknown, interaction_type gains EVENT and TASK, deal_stage_category gains N/A.
  • `sample_values` now has stated semantics. A listed value promises the spelling is right and the pipeline can emit it — not that your workspace holds rows carrying it. Its absence is deliberate: fields whose values come from your own CRM configuration (deal_stage_normalized, stage labels, ids, free text) carry no list, because any list we published would be one workspace's tail rather than a closed set. For those, group by the column and see what you actually have.
  • Semantic search now keeps up when a conversation stops qualifying, instead of only at the moment it is indexed. Applying your data filters at indexing time fixed what gets collected, but it could never revisit a decision already made — so a conversation that qualified when it was indexed stayed searchable forever, even after it stopped qualifying. That is not a rare event: on one workspace, 71% of its excluded conversations are excluded not by a filter anyone edited but by whether a matching CRM record exists yet, which changes on its own every time CRM data syncs. A filter you switch on today, or a CRM match that lands overnight, therefore left behind results that kept coming back. A new ongoing pass re-checks already-indexed utterances against your current rules and removes the ones that no longer qualify, so the index follows your filters over time rather than freezing at whatever was true on the day each conversation was collected. It also catches utterances whose speaker was later reclassified. The pass is deliberately conservative — it only removes an utterance when your workspace positively reports it as no longer qualifying, never merely because it could not be checked — and it works through each workspace steadily rather than all at once, so a filter change is reflected within a few days rather than instantly. No filter configuration needs revisiting, and nothing about the request or response shape changes.
  • Semantic search no longer returns a partial answer without saying so. Results for a workspace were drawn from a shared index and filtered afterwards, so when another workspace's conversations happened to be dense around the same topic, they could consume the whole candidate pool and your search would quietly come back with one or two results instead of ten — at full confidence, with nothing to indicate the rest were missing. Measured on the affected workspace, a colliding query returned 1 of 10 true matches. Those searches now keep looking until they've actually found your matches, so the answer is complete. They take longer in that case (around a second rather than instant); workspaces with their own dedicated index are unaffected and unchanged.
  • Semantic search no longer returns conversations you excluded with data filters. Every other read honours your filters, but the index behind the semantic lane was built without them, so utterances from conversations you had deliberately switched off could still come back as matches — measured at 22.4% of all indexed utterances across the fleet, and roughly 28–30% for the most affected workspaces. New indexing now applies the same exclusion rules as the rest of the product, and the utterances collected under the old behaviour are removed in a follow-up pass. Nothing about the request or response shape changes, and no filter configuration needs revisiting.
  • Semantic search (search semantic lane, enrich, lookalike themes) now returns materially more of what your corpus actually contains. Similarity reads previously ranked candidates across all workspaces and filtered to yours afterwards, so a smaller workspace received only its share of a fixed candidate budget and quietly lost true matches — measured as low as 5.3 of 10 against an exact scan, with individual queries returning nothing for a topic that was present. Each workspace now searches its own vector index; the same measurements land at 8.6–10 of 10. Nothing about the request or response shape changes.
  • Utterances whose embedding arrived late are no longer missed permanently by semantic search. The sweep that fills the index behind the semantic lane walks forward through *event* time, which quietly assumed an utterance is ready to index by the time the sweep reaches its timestamp. Embedding is a batch job, so that assumption fails constantly: measured across the fleet, 70,187 utterances became indexable in a single week and 26,827 of them hung off conversations more than 30 days old — already behind the sweep, and therefore never picked up. A second pass now walks *eligibility* time, so an utterance that becomes indexable long after the conversation happened is still collected. It also covers conversations backfilled late, since nothing can be embedded before it exists. Counter-intuitively this mattered most for workspaces whose indexing was fully caught up — a sweep that is still behind collects late arrivals on its way past, and only a current one can be overtaken.
  • Semantic search now covers every active workspace, not just ones that had used chat. The vector mirror the semantic lane reads was populated by a sweep that borrowed its tenant list from an unrelated pre-warm job, which selected workspaces by recent *conversation* activity. That is a sensible rule for deciding whose briefs are worth pre-warming and the wrong one for deciding whose data gets indexed: a workspace that had connected its sources but never opened a chat was skipped entirely, so its first semantic search — and every one after — silently fell back to the coarser theme index instead of matching real utterances. The sweep now selects on the workspace's own active status. Affected workspaces populate on the next sweep with no action needed; corpus.store on a search.query response tells you which index answered.
  • Deal health no longer scores the wrong person. A row on the interactions surface is a grouped turn — several utterances folded into one row and stamped with its first member's name — and /deal-health used to GROUP BY that stamp, count the groups and score the counts. On a turn holding two voices that credited one person with the other's words and with the champion / economic-buyer signal derived from them. The endpoint now reads at turn grain, verifies every speaker against the atomic utterance table, and aggregates on the verdict. 11.84% of member utterances carry a stamp that is not their own and 13.58% of turns hold more than one voice (measured live 2026-08-15), so scores move. [corrected 2026-08-16] That figure is one workspace (a7c1bd65), and it is the least representative on the fleet for this defect: every one of its multi-voice turns *also* mixes internal with external speakers, which a regroup clears — on every other workspace the reverse holds. The fleet rate, measured the same day across nine workspaces, is 7.70% of members mis-stamped (234,473 of 3,044,300) and 6.11% of turns holding more than one voice (134,510 of 2,201,887). Read the fleet figure as the general rate and the workspace figure as one workspace's.
  • What `champion_score` means now. Its arithmetic is unchanged; its inputs are narrower and correct. interaction_count counts calls the person was verified to have spoken in — which goes up for anyone who habitually shares a turn, because a co-speaker the stamp hid is now visible — and sentiment_counts counts only turns that resolve to that person alone, because sentiment_primary labels the turn's whole text and a two-voice turn's text is not one person talking. A turn whose membership cannot be read contributes nothing. Expect scores to move in both directions, and read the new per-stakeholder attribution block (attributed_turns / ambiguous_turns / unverified_turns) for why.
  • `is_economic_buyer` now requires a verified identity. It used to be role_level === 'executive' off a title that came from the turn stamp, so a group's first member could become the deal's economic buyer on somebody else's words. An identity nobody could check is no longer eligible, and its calls are reported separately as unverified_interaction_count.
  • `speaker_grain` on every deal-health response says whether the atomic source was consulted and how many rows were attributed, ambiguous, or unattributable. If the membership read fails, deal health fails closed: attributed: false, reason: "bridge_unavailable", no champion and no economic buyer named — the people, titles and companies still come back. Showing a champion nobody verified is worse than showing none and saying so.
  • Deal-health events see the second voice too. A call where two people shared a turn no longer looks single-threaded: multi_thread now fires on it and carries verified_speaker_count alongside speaker_count. executive_entry requires a verified identity — an unchecked stamp raises a plain new_stakeholder carrying speaker_attribution: "turn". Stakeholders and events are also now derived from one read of the warehouse rather than two, so they are guaranteed to describe the same population.
  • Outbound stops putting an unverified name in a sent message. The call-mention signal wrote Mentioned by <name> straight into the generated opener; on a two-voice turn that was the wrong person. It now names someone only when the atomic source verified them, and otherwise says "our team" — true of every row it reads. The transcript enrichment tool returns speaker_attribution on each mention and omits the name on an ambiguous turn; mention_count is unchanged, since it counts content matches rather than speakers.
  • Still not covered: the testimonials people picker, the sales-digest transcript, the listening-lens documents, cluster representative quotes, the people-matching and company-catalog facets, pre-generated insight context, and the public page-embed renderer. They mislabel rather than miscompute, and they are named here rather than left silent.
  • Speaker attribution now runs on every gated read of `interactions`, not just hydrated semantic search. A row on that surface is a grouped turn — several atomic utterances folded into one row and stamped with its first member's identity — so speaker_name / speaker_email / speaker_title names the wrong person on 11.84% of member utterances, and 13.58% of turns hold more than one voice (measured live 2026-08-15). [corrected 2026-08-16] That figure is one workspace (a7c1bd65), and it is the least representative on the fleet for this defect: every one of its multi-voice turns *also* mixes internal with external speakers, which a regroup clears — on every other workspace the reverse holds. The fleet rate, measured the same day across nine workspaces, is 7.70% of members mis-stamped (234,473 of 3,044,300) and 6.11% of turns holding more than one voice (134,510 of 2,201,887). Read the fleet figure as the general rate and the workspace figure as one workspace's. The previous release fixed search.query with hydrate: true and said plainly that reading the surface directly still returned the stamp. It no longer does: data.query, the search filter and fuzzy lanes, the curated fast-lane templates, page-template SQL and blueprint query steps all go through one SQL gate, and the gate now resolves the turn's real membership from the atomic utterances table before the rows leave it.
  • What you get back on a turn-grain row. speaker_attribution: "utterance" means the speaker was verified against the atomic source — a correct stamp is now distinguishable from an unchecked one, which it never was before. "ambiguous" means the turn holds more than one speaker: all four speaker fields come back null, turn_speakers lists everyone in play, and nobody is named. "turn" means the value is still the unverified stamp. speaker_ambiguous remains the separate question of whether the row's text spans more than one speaker.
  • Stakeholder columns travel with the speaker. role_level, is_champion, is_economic_buyer, champion_score and inferred_persona are derived from the stamped speaker, so they are nulled alongside it rather than left describing someone else.
  • Two shapes cannot be repaired, and now say so instead of staying silent. A projection with no `interaction_id` cannot be resolved to its turn, and an SQL-side aggregate (GROUP BY speaker_name) folded the stamps before anything could check them. Both come back with every row labelled speaker_attribution: "turn" and an envelope block, speaker_grain, carrying attributed: false plus the reason (no_turn_key). The label ships on the row, not only in the envelope, so it survives being copied out. Select `interaction_id` alongside your speaker columns and you get verified attribution instead of the label.
  • `speaker_grain` on `data.query` and on `search.query`'s `corpus` block names the columns at risk, whether the atomic source was consulted, and — when it was — how many rows were attributed, ambiguous, or unattributable. It is emitted whenever a speaker-bearing column is returned, including when nothing was wrong; its absence means only that no such column was in the projection.
  • The objection / pain-point / feature-request / success-story / competitive-mention fast-lane templates now select `interaction_id`, which is what turns their "here is a quote and here is who said it" output from an unverifiable stamp into a checked attribution.
  • `speaker_type` is unaffected and is left alone. Grouping breaks a turn precisely when speaker_type changes, so every member of a turn shares one — a query selecting only speaker_type gets no label and pays no extra read.
  • Not covered by this change: paths that read the warehouse outside the SQL gate — the /deal-health stakeholder endpoint, the testimonials people picker, the sales-digest transcript, the listening-lens documents, cluster representative quotes, the outbound call-mention and transcript tools, and the agent BigQuery tools. They still return the turn stamp. The deal-health endpoint is the one where it compounds rather than merely mislabels: it derives champion and economic-buyer identity from the stamp, so a mis-attributed turn moves a score.
Breaking
  • Evals: `overall_score` is now the grade of the copy YOU submitted. It used to be a mean over every applicable grader in the case, and those graders judge two different artifacts — the hygiene rules score the text you sent, improvement_loop scores the improved version the eval wrote. The mean of the two was a grade on neither, and it was the field at the top of the payload, so it was the one that got quoted: a live run returned overall_score: 1.0 for a draft that cleared 2 of 5 rubric lines. It now reports the submitted side, on the same [0, 1] axis, read from the same derivation as verdict.headline.submitted and compare.submitted_score — so those three cannot disagree. Nothing about the grading changed: same rubric, same customer evidence, same judge, same prompts. What changed is what one stored field denotes. The run's pass / partial / fail bucket still follows the IMPROVED side, so a pass beside a low score is now the ordinary shape of "your draft did not clear the bar, the rewrite did" rather than a contradiction — gate on the verdict, read the score. null on a refusal is unchanged (never 0, with not_applicable_reason beside it), and an eval with no improvement_loop grader is unaffected, because every applicable grader there already judged what you submitted.
  • Evals: the boundary is `eval_version` 2.14.0, and scores must not be pooled across it. prompt-and-message-eval moved to 2.14.0 with the change above. That version rides the run fingerprint and is stamped on every stored run, so it is what tells you which reading a row carries — the two readings share a name, a type and a range, and nothing else distinguishes them. If you average, trend, threshold or sort `overall_score` across stored runs, filter on `eval_version` first, or read `verdict.headline.submitted.score_15` instead — that field means the same thing on both sides of the line and is present on (or derivable for) every run. GET /eval-runs/{id}/compare/{other_id} does not check versions: score_delta differences overall_score, so a comparison spanning 2.14.0 differences two different quantities; read submitted_score_delta there, which is derived from the improvement report and is unaffected. GET /evals/<slug>/kpi already tracks the submitted side from the headline and needs no change.
Added
  • Eval runs now record and report who fired them and from which surface: triggered_by_kind distinguishes console / api / mcp / agent (was always manual), and run reads carry an additive triggered_by_name.
  • Evals: every report now hands back the calls that produced its evidence. The report already told you which quotes backed a claim; it now tells you how to get those quotes yourself. grader_meta.evidence_provenance.requests is a list of { op, params } — the actual reads the run issued (data.cluster_search with its query and limit, data.query with its full SQL) — deduped so N quotes drawn from one leg appear as one call, not N. Run them against your own workspace to reproduce the evidence, change them to explore adjacent ground, or paste them into a prompt so your own agent retrieves the same way. Everything else in the provenance block is a count, and there is nothing you can do with "we retrieved 60 quotes"; this is the part you can act on. Your key supplies the workspace, so no tenant id appears in any request — a call that looked copy-pasteable while naming someone else's workspace would be worse than one that omits it. The field is absent (not empty) on a run that reused pinned evidence rather than issuing calls, because that run genuinely made none. In the console the same list renders under "Show the N calls behind this evidence", each with a copy button.
  • Evals: a run now tells you which input would have made it better, and what to send. Account tier is the only evidence tier that licenses a claim about what a specific company said — and a run given no account cannot reach it, which is why suggested research steps kept coming back with an unfilled {{account_name}} in them. The verdict now carries requires_input: the field that was missing, why it matters, and — when a company named in your draft resolves against your own company_name rows — the exact value to re-run with, plus how many utterances sit behind it so thin coverage is visible before you spend a second run. It is reported, never applied: nothing on the payload was retrieved using a suggestion, and the run graded exactly what you sent. A wrong account would attribute one company's words to another, so the value is offered and never assumed — only an account you send changes what gets retrieved. suggested is omitted rather than guessed when nothing verifies, and the whole field is absent when the run had what it needed. There is deliberately no interactive question: a tool that blocks to ask would deadlock every headless caller, so the gap ships as data and your agent decides whether to re-run with it, put it to a human, or accept corpus-tier grading.
  • Eval runs now hand back the evidence pin instead of expecting you to know it. Every settled run whose quote pool was recorded carries a regrade block on GET /eval-runs/{id} and GET /eval-runs/{id}/gate — the pasteable evals.run params (evidence_from_run plus the eval slug) that grade your edited draft against the SAME customer quotes, so the score delta is your edit rather than a retrieval change. The pool itself is now readable at GET /eval-runs/{id}/evidence (MCP: the evidence action on the evals tool): every frozen quote with its id, tier and provenance, exactly what a pinned re-grade is scored against. Pinned runs also stopped losing quote provenance — the stored speaker side, spoken-at date, seniority and deal stage now survive the read, so a pinned report renders the same attribution as the run that first retrieved the quotes.
  • Evals: `GET /eval-runs/{id}/evidence` now says which pooled quotes the judge actually read. A run retrieves a pool (often ~60 quotes) but only its leading slice reaches the graded prompt, and grader_meta.evidence_provenance has always reported both counts — so a small cited against a large pool was explainable but not checkable. Every quote on the pool read now carries `reached_judge`, and the set carries `in_context` (how many leading quotes went into the prompt). Read reached_judge as THREE states: true reached the judge, false was retrieved but never shown, and ABSENT means the run recorded no count — runs graded before this shipped, and the field is omitted rather than defaulted to false, because false would assert that a quote the model demonstrably read never reached it. The count is written by the grader that froze the pool rather than derived from a constant, because graders differ: improvement_loop sends a 20-quote prefix, evidence_judge sends its whole retrieval.
  • Evals: a run whose only evidence grader is `evidence_judge` now records a pool at all. Previously only improvement_loop persisted one, so GET /eval-runs/{id}/evidence answered evidence: null for those runs and evidence_from_run had nothing to pin. Both graders now record. On an eval declaring both, the improvement_loop pool wins — it is the one the rendered report is assembled from — and the second write never overwrites it.
  • Evals (wire change, beta endpoint): pooled quotes on `GET /eval-runs/{id}/evidence` are now snake_case. speakerSide / spokenAt / roleLevel / dealStageAtTime become speaker_side / spoken_at / role_level / deal_stage_at_time, matching the cited quotes in a verdict exactly. The pool read was emitting stored keys verbatim, so one utterance arrived in two different shapes depending on whether you read it from the pool or from the grading — anything rendering both had to reconcile them by hand. Pooled quotes still carry id and carry no stance (a stance is a judgement about a quote the judge cited).
  • Export your graded runs as training data. GET /evals/export.jsonl?format=pairs|sft|judge reshapes each line into a training record instead of the stored verdict: pairs is preference tuples (your draft as rejected, the improved version as chosen) for DPO/ORPO, sft is instruction to completion with the improved copy as the target, and judge is the grading task itself - artifact and evidence in, per-dimension verdicts out. Read this before you train on it: both sides are model-generated, and the same rubric wrote and graded them, so these are distillation examples - a smaller model learning to imitate this pipeline - and never ground truth for what a human expert would write, nor evidence that the improved copy performs better with buyers. Every record carries that statement on its own face (provenance.both_sides_model_generated), per record rather than in a header, because training pipelines shuffle and split and a header is lost on the first of those. A training file pins one `eval_version` and is refused otherwise, naming the versions you have so you can pick: the rubric and the generation prompt both changed across versions, so "chosen" does not mean the same thing in 2.12 as in 2.20 and a mixed file trains toward the average of two standards while looking like one dataset. Rows we cannot honestly label are left out rather than labelled anyway - a pair whose lift sat below the measured noise floor (the run's own instrument declined to claim a difference), improved copy still carrying a [placeholder] or an <unverified> marker, a "before" the eval wrote itself on a prompt-only run, and the second grading of a message already in the file.
  • Evals now serve pipelines, not only people. inputs.mode: "gate" grades ONLY the copy you submitted and stops (no rewrite, no suggestions - one judge call, so markedly cheaper and faster), and GET /eval-runs/{id}/gate (also the MCP evals tool's gate action, with the same wait_ms long-poll) returns the machine verdict on every run: gate.passed, checks_passed/checks_total, score_15 vs threshold, basis, simulated, and dimensions_failed. Wire THIS into a send/hold or CI gate - never verdict, overall_score or lift, which follow the rewrite the eval wrote and pass ~92% of runs. gate is null while running and on a refusal (not_applicable_reason says why); a refusal is not a fail. prompt-and-message-eval bumps to 2.17.0 (definitional; existing modes grade byte-identically).
  • Evals: the improved message is now checked for people nobody can identify. The rewrite could name a third party it lifted out of a customer quote — "Colin's idea stuck with me too" — and pass every existing check, because it _was_ grounded: a faithful paraphrase of a real retrieved quote. Grounding asks whether cited evidence resolves; it cannot ask whether anyone knows who the person IS. On the run that prompted this, that name appeared exactly once in the whole workspace corpus and matched no contact, no user and no speaker anywhere. The improved-message facet now reports names_total (attribution phrases found, the denominator), names_unlicensed (those naming someone you never named in your own prompt or draft) and names_annotated, and an unlicensed name is wrapped in the same <unverified> markers an ungrounded claim already gets. The rule needs no roster: retrieved quotes deliberately carry no speaker identity, so a name lifted from quote text is unverifiable by construction, and your own words are the only place a person is confirmed. Detection is deliberately narrow — it matches attribution phrasing (Dana's suggestion, Sam said, per Alex), so an organisation possessive like "Gong's analysis" is not flagged, and a bare mention with no attribution is not caught.
  • Evals: `prompt-and-message-eval` is now `eval_version` 2.20.0, and your scores are still poolable across it. Unusually for a bump, nothing about the grading moved: no prompt changed, no model call was added, and the markers are applied after the blinded judge has already graded the clean copy — so score_15, the dimensions, the lift, the transition and overall_score all describe exactly what they did at 2.19.0. The boundary exists for one reason, and it matters if you post-process the text: if you re-derive anything from a facet's `text`, you must now check `claims_annotated` _and_ `names_annotated`. Since 2.15.0 the rule was that claims_annotated: 0 meant the field was byte-identical to what the judge scored; a second, independent source can now mark the same field, so that check alone is no longer sufficient. Both counters 0 (or absent) means the same bytes. Both sources share one marking pass and one marker vocabulary, so stripping markers still recovers the graded text exactly.
  • Evals: `GET /eval-runs/{id}/drafts` returns the two graded texts side by side. The run grades a pair — the prompt and message you submitted, and the improved versions it wrote — and no read returned the pair: the report card renders scores, findings and quotes but never the texts, /evidence is the quote pool, /gate is the machine verdict. The only path was the full-run read, which measures p50 65 KB and p95 84 KB, so a size-capped client could truncate it and drop exactly the copy you submitted while leaving every score intact. The new read is bounded (per-dimension quotes stay on /evidence) and carries each side's score, checks fraction and per-rubric-line reasoning, so a line-by-line comparison is one call. Also available as the drafts action on the MCP evals tool. Three fields tell you what you are holding: source names which stored copy answered, annotated says whether <unverified> markers were stripped from it, and absent_reason says why a side is empty — a refused run reports improved: null with a reason rather than an empty string or a zero.
  • Evals: an oversized read now says so instead of being silently cut. When a read's payload crosses its declared size budget, the response carries a payload_note naming the size and the narrower reads that return the same information in a bounded shape. GET /eval-runs/{id} declares one, pointing at /drafts, /report, /gate and /evidence. Nothing is dropped from the payload — the note is additive, so an existing integration is unaffected — but a caller who previously concluded a field did not exist can now tell that the read was too big for their transport.
  • Take your graded eval runs somewhere else. POST /evals/export returns flat rows for analysis (also the evals MCP tool and the Anthropic surface); GET /evals/export.csv and GET /evals/export.jsonl stream the same filtered set as a file - CSV a row per run, JSONL the stored verdict verbatim. Filter by eval, eval_versions, from/to, statuses and verdicts. Call count_only (or GET /evals/export/count) first: it answers over the identical filter and reports the version spread, so you learn your rows were scored by more than one instrument before you pool them rather than after. Two refusals rather than a quiet wrong file: over 5,000 matches is refused with the real total and the remedy (narrow the range, or pass allow_truncation to take the most recent 5,000 deliberately), and every number that needs a qualifier ships beside it - overall_score with applicable (a blank cell is never a zero), lift with lift_reportable, the submitted score with input_simulated. You export your own runs; a workspace admin exports the workspace. redact_quotes blanks the verbatim customer quotes on the JSONL download; CSV never carries quote text. Scope evals:read.
  • Evals: `GET /eval-runs/{id}/improvement` — the before/after report in a shape that fits your transport, because the advice we were giving you did not. A completed run's detail payload runs 65–205 KB, so it attaches a note telling you it may be truncated and pointing at four narrower reads. None of those four carried an `improvement` block. So a caller who did exactly what we told them lost improvement.facets[] — the per-facet graded pair, with each side's score, per-rubric-line verdicts, cited quotes and not-applicable counters — and reasonably concluded the field comes back empty. It did not: measured on the run that produced this report, facets had length 2 with both counters populated. The advice was the bug. The new read returns the whole report — every facet, plus lift, transition, suggestions, prompt_patch, research_steps, confidence, coverage and the before / after mirror — and omits exactly two fields, which it names on every response in an omitted[] saying what each was and where it still lives: grader_meta (blinding and evidence-provenance disclosure) and stage_trace (per-stage timings). Those describe how a run executed rather than what it found, and they are ~38% of the block's bytes, which is what lets the rest fit — 41 KB measured on a run whose full payload was 96 KB. It is on REST (GET /eval-runs/{id}/improvement), the Anthropic read surface, and MCP both as the resource eval_run://<id>/improvement and as a new improvement action on the evals tool — the sibling reads the note names all had an action already, so following the advice should not have meant dropping to a raw resource read for the one door carrying the facets. It polices its own budget like every sibling: a run that still exceeds it says so rather than being quietly trimmed. A run that produced no report returns `improvement: null` with an `absent_reason` — never an empty object and never an empty facets array, because an empty array is exactly what a caller coalescing an absent parent already sees, and the two must not be the same bytes. Separately, the truncation note itself now says what each narrower read carries instead of listing bare URIs and claiming they all hold "the same information" — that claim is what sent the original caller through the wrong door.
  • Evals: `verdict.headline` now carries `stop_reason`, so you can tell a converged rewrite from a cut-off one without walking the report. The revision loop already recorded why it stopped — threshold_met, no_critique, deadline, model_call_cap, max_iterations — but only at cases[].graders[].improvement.transition.stop_reason, six levels down. The headline is the projection the docs point gating callers at, and it carried only the English sentence ("The revision loop ran out of time before it converged") buried in confidence.reasons[], which is not something to branch on. The enum now rides the headline too, read off the same transition block so the two cannot disagree. Worth knowing what it means when you see it: `deadline` is the expected outcome, not a malfunction — at the shipped configuration a run whose first round misses the bar and still has a critique to act on will usually report it. It costs you a further revision pass and no score: the reported grade is the first round either way, and overall_score is the submitted side alone, so a cut loop cannot move a number in either direction. Treat an absent stop_reason as unknown rather than as converged — the whole transition block is omitted when the unpaired absolute grade is unavailable. The docs also now state where the improvement block lives and why it can look empty: facets is never returned empty, so an empty read means the whole parent was absent, which happens in mode: "gate" and on every refusal (empty_corpus, not_outreach, generate_failed, …) — read not_applicable_reason beside it. The narrower /drafts, /report, /gate and /evidence reads carry no improvement block at all, which matters because a large detail payload emits a note pointing you at exactly those.
  • `GET /console/data-filters/manual-excludes` — read back what you excluded by hand. Excluding a single conversation stores its id and nothing else, so the console could only list raw ids: unreadable, and therefore un-undoable once you forgot which was which. The new read resolves each id into the conversation's title, kind, connector, linked company and date, plus who excluded it, when, and the reason they gave. Also available as console://data_filters/manual_excludes (scope config:read). What it does NOT do is guess: metadata we cannot resolve comes back null rather than blank-looking defaults, an id the warehouse has no row for still appears in the list (it is still excluding data), total counts the exclusions on the rule rather than the rows that happened to hydrate, and a warehouse outage sets metadata_unavailable_reason instead of failing — so "this conversation has no title" stays distinguishable from "we could not look anything up". An exclusion whose author cannot be established reports excluded_by: null in preference to a name that might be wrong.
Changed
  • The eval report card renders plain ASCII hyphens. GET /eval-runs/{id}/report returns markdown built to be pasted verbatim, and it shipped an em dash in its ## Eval report heading on every run plus four more in its own sentences - the one surface in the product whose whole job is to be copied out, demonstrating the punctuation the house style tells every model not to emit. Only the punctuation moved: no field, score, verdict, lift or threshold changed, and the headline object beside the markdown is byte-identical to before. If you match on the report's heading text, match on ## Eval report - <slug> rather than the em-dash form.
  • Authoring an eval now rejects stylised dashes in the text you write. A rubric line, case label, description or field help you author is rendered into the judge's prompt alongside the house style rules, so a curly dash there teaches the model by example while the rules above it say not to. POST /evals / PATCH /evals/{slug} (and the write-free POST /evals/validate dry-run) now return a validation_failed naming the exact field path, and the fix is one character. This checks only the four non-ASCII dashes; quotes, ellipses and other typography in your text are untouched.
  • Evals over MCP: the `status` action now tells your agent what to do when a run was missing an input. requires_input shipped on the verdict a version earlier, but the tool description never mentioned it — so an agent reading the report had a field it had no reason to look at, and the follow-up run that actually uses it never happened. The description now names the field, says account is the usual one, and states the part that decides whether re-running is safe: nothing was retrieved using a suggestion, so the value only takes effect when you send it. Nothing about grading changed and no number moves; this is the signal that makes the loop closeable without a human in it.
  • Grading changed in five ways at once, and scores from before this release cannot be compared with scores after it. The judge now sees a worked pass example and a worked fail example under each of the five rubric lines, so the same draft is measured against the same stated bar every time. Your draft's own score is now produced by grading it on its own, without the rewrite beside it. On our test set that reads about 0.3 checks lower and varies about three times less between runs, so a lower number after this release is the measurement changing rather than your copy getting worse. The lift between your draft and the rewrite is still a paired comparison and is unaffected. Declaring an artifact_type now also tells the writer what it is writing (previously it only told the grader), so for landing copy the rewrite stops adding a single named account and a send/hold rule. A declared artifact_type also now settles on its own which rubric lines do not apply, without the grader overriding it.
  • Export downloads stay limited to one a minute per person, but the limit is now shared across replicas and the refusal tells you exactly when it lifts. A 429 carries retry_at (an absolute instant) alongside retry_after_seconds, and Retry-After is the real remaining time rather than a flat 60 regardless of how much of the minute had passed. GET /evals/export/count reports the live cooldown too, so a UI can show a correct timer the moment it opens and after a page reload - an absolute instant survives a reload where a countdown cannot. Two smaller fixes ride along: a request refused as invalid (a typo in ?eval=) no longer costs you the minute, and the limit no longer depends on which server answered - it used to be per-process, so two clicks a second apart could both succeed and two a minute apart could both fail.
  • The eval-run file exports have no row cap. GET /evals/export.csv and GET /evals/export.jsonl now page through the match set with a cursor and write each page to the socket as it arrives, so peak memory is one page whatever the filter matched - a 40,000-run export costs the server the same as a 400-run one. The 5,000-row refusal is gone from those doors: a workspace's own runs are its own data, and the cap was a memory bound rather than a policy. POST /evals/export keeps the cap, because it holds every row in one JSON response body; its refusal now names all three remedies (narrow the range, set limit, or take the file). They also compress - send Accept-Encoding: gzip (every browser does; curl needs --compressed) and the body arrives gzipped under Content-Encoding: gzip, roughly a tenth of the bytes, decompressed transparently and saved under the same filename. Paging is keyset rather than offset on purpose: eval_runs receives writes while an export streams, and an offset counts from the top of a set growing underneath it, so it silently skips rows it never showed and repeats rows it already did - neither visible in the finished file.
  • New `limit` on every export door - take the most recent N runs. A deliberate top-N, not a truncation: it never needs allow_truncation and is never refused for matching a larger set. On the file doors it is the only thing that bounds the row set.
  • New `verdict_counts` on the export count - the per-bucket split (pass / partial / fail / not_applicable) plus ungraded, the runs carrying no verdict at all. Those runs have an empty overall_score cell (a refused run was never graded, and a zero would report the worst possible grade), so averaging that column without knowing how many empties are in it is the easiest way to misread an export - now you learn the number before you download rather than after. ungraded is deliberately kept out of buckets: absence of a verdict is a different fact from a verdict of not_applicable.
  • Fixed: the export count could under-report its own version spread. versions and the date range were derived from a single read that also carried a count, and PostgREST caps returned rows - so on a large set the count stayed exact while the sample behind the version list came back cut. The one warning that tells you your runs were scored by more than one instrument could itself miss a version, with nothing in the response saying so. The total is now a head count and the version spread is paged, so it is complete rather than sampled.
  • Fixed: every export download returned a 500. The SELECT named a completed_at column that eval_runs does not have (it is finished_at), and PostgREST rejects the whole query on an unknown column - so this was not a missing field in the output, it was a hard failure on every .csv and .jsonl download and every POST /evals/export since the surface shipped. A tripwire now reads the column set out of the migration that creates the table and fails a build that SELECTs a name the table does not have.
  • New `ungraded` verdict filter. verdicts accepts ungraded alongside the four real buckets - the runs carrying no verdict at all, i.e. the ones whose overall_score cell is empty. It compiles to an IS NULL predicate rather than passing through as a bucket value (no run stores verdict: "ungraded"), so the number verdict_counts.ungraded reports is now a set you can isolate or exclude rather than one you can only read.
  • Fixed: the version list sorted lexically. versions used a plain sort, so 2.11.0 came before 2.2.0 and a workspace spanning eighteen versions got a list that looked shuffled - with the "these runs span N eval versions" warning beside it reading as noise rather than a timeline. It now orders numerically per dot-separated segment.
  • The eval KPI now publishes a count for each of its two means. n_scored has always been the number of runs behind submitted_mean_15, and improved_mean_15 is taken over the runs that carry an improved-side grade, which had no count of its own — so a reader could attribute it to a denominator it was never computed over. Series buckets and both window stats now carry n_scored_improved beside it. The improved-side window mean also abstains below 3 runs against its own count rather than against the submitted count, so it can read null in a window where the KPI itself is reported.
  • The two window means now abstain independently, so one payload state is newly reachable: a window with fewer than 3 submitted grades and 3 or more improved ones returns abstain_reason: "thin_window" and submitted_mean_15: null beside a non-null improved_mean_15. abstain_reason answers for the submitted side, which is the KPI. Whenever it is present, read submitted_mean_15 as withheld regardless of what the improved mean reads.
  • The eval report and the evals run result now lead with the SUBMITTED fraction (the rubric checks your own copy cleared) and the specific lines it missed, instead of the composite 1-5 or the improved-side number. Those figures are still present, they are just no longer the headline: the improved side clears the bar on most runs by design, so leading with it reads as pass/fail when it is closer to a constant. The number that is about your writing is now the first thing you see.
  • The run result now surfaces the exact quotes the run graded against, alongside a regrade hint: draft from those quotes and re-run with evidence_from_run set to the same run id, so a second draft is scored against the same evidence rather than a fresh retrieval. This closes the case where legitimate personalization was penalized because the draft and the grade drew from different pulls.
  • The queued run acknowledgement always carries console_url (a link to the report) and a poll block (the status action, the run id, and the wait_ms cap), so reading back an async grade is one obvious step rather than guesswork.
  • The Prompt and Message Eval now rewrites by default, and generated copy carrying an unfilled placeholder can no longer read as passing. Two halves of one reported defect. `inputs.mode` defaults to `rewrite` (it was advisory): submit a prompt and you now get an improved prompt back, graded as a blinded pair against yours. The old default was inverted — sending NO prompt returned a full improved one, while sending a prompt, the case where you are asking for it to be improved, returned none, because advisory instructs the writer to leave the improved prompt empty. advisory is unchanged and one input away; it is still the right mode for a living rules document your team is not going to replace. And the improved message is checked for unfilled placeholders{{token}}, [Insert the metric], [Company] — with a non-zero count withholding transition.improved_verdict: "pass" whatever the rubric scored, and the offending spans quoted in transition.explanation. A message with a slot in it is a template, and a live report presented one as ready to send while the judge had already scored that line 1/5 for the unfilled bracket. No score moves: the count is taken after grading, so score_15, the dimensions, lift and overall_score are unchanged — only the pass claim. The new placeholders_unresolved counter ships on the improved message facet (and only there: a reusable prompt is supposed to carry slots). prompt-and-message-eval bumps to 2.21.0; do not pool improved_verdict or transition tallies across that line, nor prompt-facet scores for runs that omitted mode.
Fixed
  • Evals: a rubric dimension can no longer be marked not-applicable and scored PASS at the same time. A graded facet's not_applicable flag states that nothing of that kind of artifact could satisfy the dimension however well written — so a candidate that scored PASS on the same dimension in the same grade was direct counter-evidence, and until now nothing reconciled the two. A pass: true now vetoes a not-applicable claim on that dimension for both the submitted and improved side of the pair (score wins over abstain); the dimension is graded exactly as it always was, and no headline number, verdict, lift or overall_score moves. Every improvement.facets[].before / .after block now also carries contradicted_not_applicable, a third reported-only counter alongside the two that already track the caps on this annotation, emitted even when zero.
  • Evals: the research steps a report suggests were being silently dropped, and now they are not. The report hands back runnable Amdahl calls — "here is the exact query that answers what your message was guessing at" — and every one is validated against the live registry before it ships, so a call you cannot run is never shown. What that validation could not do was tell the model what each call REQUIRES. The kit it was given listed <id>: <name> and nothing more, so it had to guess parameter names, and the validator then rejected the guess against the very list the model had never seen. Measured over 40 drafts: 156 steps proposed, 107 kept — a 31.4% drop rate, with 38 of 40 runs losing at least one step, every drop for a missing required parameter and 34 of them the same call (lookalike.similar_themes, labelled "Similar Themes" but keyed on query). The kit now names each call's required parameters, so the steps that were being binned should survive. Nothing about which calls are OFFERED changed — the surface is still exactly what your key can run. prompt-and-message-eval moves to eval_version 2.25.0 and scores are not poolable across it: the kit block sits in the same prompt that writes the improved copy, so the copy can shift as well as the steps.
  • Eval grading no longer marks a passing check as failed when the judge explains itself with a list. A check whose reasoning said something like "no numbers, outcomes, or account-specific facts are invented" was being read as an admission that the draft was ungrounded, and the pass was dropped — measured at about 3% of passing checks, concentrated on the evidence-grounding line. Scores on affected runs were too low, never too high, so a re-run after this change may read slightly higher.
  • Two claims on already-stored eval reports now read correctly everywhere they are shown. A run that graded nothing (every case refused) reported overall_score: 0 - the worst grade on the scale, standing in for the absence of one - and sometimes carried a passing flag beside it; it now reports no score, and the refused case no longer ships pass flags for a judgement it never reached. And an improved message still carrying an unfilled [placeholder] no longer reads "Improved: passes": that gate shipped in eval 2.20.0, but the overwhelming majority of stored reports predate it and kept claiming a pass on a template. No number moved. The per-dimension scores, score_15, lift and each side's own grade are exactly what the judge produced - this corrects the pass CLAIM and the never-graded score, nothing else - and the stored run still records what it concluded at the time; the correction is applied when the report is read, so it reaches reports written before the fix as well as after.
  • `grade-and-report` skill: bounded the published "improved side clears the bar on 92%" statistic. The skills/grade-and-report/SKILL.md guidance (also published verbatim in the public amdahl-cookbook repo) now states the eval_version (2.9.0), sample (n = 89 runs / 40 distinct messages, one tenant), and a prior reading (71.0% at eval_version 2.7.0, on a partly different message population) behind the figure, and is explicit that the two readings are not a controlled before/after — the difference between them is not attributed to the version change. The guidance itself (use the submitted fraction, not lift or transition) is unchanged.
  • Eval verdicts got three honesty fixes: the run verdict now reflects your SUBMITTED draft (not the improved rewrite), a dimension pass whose own reasoning says the claim is unsupported resolves to unreadable instead of counting, and a message below the word floor beside a viable prompt is set aside with a named note rather than scored. prompt-and-message-eval moves to eval_version 2.16.0.
  • Evals: the docs now say where `status` actually is when you poll, and the API reference stops returning an empty object for it. Submitting a run answers with status at the top of data; reading the run back nests the row one level deeper, at `data.run.status`. Nothing said so. There was no JSON example of that response anywhere in the guide, the endpoints page printed the verdict with *both* wrappers stripped directly under the poll request, and the OpenAPI 200 for GET /eval-runs/{id} was literally {"data": {}} — so every published source a caller could copy from was either absent or wrong, and the one correct implementation was a shell script inside a skill. A hand-rolled poller reading data.status there matches undefined, which is not an error, so it loops until something else kills it. The terminal value is also complete, never completed — the second spelling belongs to an older, unrelated surface and is a natural wrong guess. All of it is now written down: the real response body is shown with its envelope, a table gives the path to status on each of the five reads that expose it (they differ — only the detail read nests), the complete spelling is called out, and poll.wait_ms_max is documented, which is the 30-second server-side long-poll that means you should not be writing a poll loop at all. The API reference now publishes the true response schema, enum included. Two response samples that shipped without their data envelope (/drafts, /gate) are corrected, and the docs check that is supposed to catch that class now recognises these shapes — it was keyed to a marker list that none of them appeared in, so they passed CI while being wrong.
  • Evals: a run that crashed mid-revision no longer reports that it finished normally, and a run that timed out before grading anything no longer reports that the engine broke. Two values were being reported for situations they do not describe. transition.stop_reason is seeded with max_iterations — "the configured round allowance was simply used up" — and the three exits taken when a LATER round errors were breaking out of the loop without overwriting it, so a run whose second round crashed was indistinguishable from one that calmly ran to its limit. Those exits now report a new round_failed, so the five existing values keep their meanings and the sixth carries the case that had been borrowing one. This matters beyond labelling: stop_reason is a partition key — comparing two runs withholds the delta on stop_reason_mismatch, on the reasoning that the loops did different amounts of work — so a mislabelled crash read as two runs having done the same work. Separately, when the phase budget expired before ANY round completed, the run returned the same generate_failed an actual engine fault returns, and the reader was told "we could not produce an improvement report", which describes a break that did not happen: nothing errored, the clock ran out. That case is now its own loop_deadline with a sentence that says so. The machine-readable not_applicable_reason is unchanged (report_unavailable), so there is no new value to handle — the distinguishing detail rides evidence.reason, where a timeout previously could not be told from a fault at all. If you have been counting how often runs time out, your number was low, by exactly the runs that never got a round out. prompt-and-message-eval moves to eval_version 2.26.0.
Added
  • Evals: a graded facet now says how many not-applicable flags the caps threw away. A rubric dimension can be marked structurally inapplicable to the kind of artifact under grade, and two independent caps bound that — one refuses the grader's whole list when it names more than three dimensions on a facet, the other truncates the merged list so at least two rubric lines always stay scored. Both were silent: a flag the budget refused and a flag never raised produced identical bytes, so "how often is this binding" could only be answered by grading again. Every improvement.facets[].before / .after block now carries truncated_not_applicable and discarded_not_applicable, emitted even when zero — absent means the run predates the counters, never that nothing was dropped. Both are reported and never read: no score, verdict, lift or overall_score is computed from them, and the caps themselves are unchanged.
  • Evals: re-run the judge over a fixed improved version. evals.run accepts candidate_from_run — a prior run id whose improved version this run should GRADE, instead of writing a new one. No generate call is made, so a score difference between two such runs is the judge rather than the writer; without it, re-grading the same submission mixed the two and neither could be measured. It also pins that run's customer quotes automatically (the frozen text's citations address the pool its own run froze, so grading it against a fresh pool would silently re-point every one of them) — send the same id as evidence_from_run or omit it; a _different_ id is refused. A run that submits different copy, or a different mode, than the pinned candidate was written for is refused rather than graded, because the judge scores your submission and the candidate as a pair. The response echoes a candidate_pin block (source run, when the text was frozen, the mode it was written under) and the report carries grader_meta.candidate_scope beside the existing evidence_scope. Pass reuse: "force" when sampling repeatedly — two identical pinned requests share a content address and otherwise join into one run.
  • Evals: the KPI trend — is the writing improving over time. One run is a report card; GET /evals/<slug>/kpi (resource eval://<slug>/kpi, and the MCP evals tool's new kpi action, which defaults the slug so { "action": "kpi" } alone answers the ask) aggregates an eval's completed runs into the trend a team tracks like a metric: a series of day / week / month buckets, current + prior window stats, and delta_15 for the movement. The trend tracks the SUBMITTED-side score (verdict.headline.submitted), never the blended overall_score — that blend mostly grades the rewrite the eval wrote, so a trend over it would measure the rewriter rather than the team. Honest by construction: a window with fewer than 3 scored runs returns null means plus a named abstain_reason (no_runs / thin_window) instead of asserting a mean over noise, refusals raise refusal_rate but never drag a mean toward zero, empty buckets are gaps rather than zero points, and a capped fetch says truncated. Scope evals:read; the console renders the same numbers as a band above the compose panel.
Changed
  • When an eval run names an account it cannot ground against, the report now tells you which of two opposite things happened, and carries the sentence that says what to do about it. account_abstain_reason used to collapse both into one no_conversations, so the only actionable part of the answer was missing: not_in_corpus means no company matched the name you typed — names are matched as written, so a brand name your CRM records differently (LlamaIndex where the record says Runllama) lands here with a full conversation history sitting under the canonical spelling — while no_quotable_utterances means we do hold that account and nothing it said clears the citable band, every utterance being internal, under 40 characters, or over 2,000. The first is fixed by retyping the name; the second cannot be fixed at all. A new account_abstain_detail carries the specific half beside the reason — which name found nothing, or the account's real utterance count when none of them were citable — and the same text rides the live account_resolved progress step, so it is readable while the run is still going. no_conversations is no longer emitted, so add the two new values if you switch on this field. The eval version moves to 2.8.0, which means a previously cached run of the same content re-grades rather than replaying a verdict carrying the retired label.
  • Evals: the cited-quote count now says WHICH side cited. A run grades two artifacts, so quotes_cited — one blended number — was read as customer evidence weighed against YOUR copy. On 65% of measured runs none of it was: the draft cited nothing and every quote was cited while grading the rewrite. verdict.headline and the report card's headline now carry quotes_cited_submitted and quotes_cited_improved beside an unchanged quotes_cited, the card's footer reads "3 customer quotes cited (0 grading your draft, 3 grading the rewrite)", and overall_reasoning closes with "Your draft cited none; the rewrite cited 3." The two figures are stated, never summed — a quote cited on both sides is one quote in the total and appears in both counts, and a quote carried only by a suggestion is in the total and in neither.
  • Docs: the verdict.headline reference published a FLAT shape (submitted_score_15 / submitted_checks / improved_score_15 / case_id) thirty lines after prose telling you to read submitted.score_15. No response has ever carried those field names. All three headline blocks are now the real nested object with its complete field set, including sentence, what_changed and facet, which were missing everywhere. The run-comparison section's flat names are unchanged — that surface really is flat.
  • Evals: when you name an audience or an account, the customer quotes you are graded against are now picked for your draft rather than by date. The account and cohort evidence legs drew the most RECENT utterances, so which quotes reached the judge had nothing to do with what you wrote — and on a cohort-scoped run those are the only quotes that can back a claim about that cohort, because workspace-wide quotes are explicitly not allowed to speak for it. Both legs now rank on the claims your draft actually makes first and recency second, using the same reading of your draft the corpus search already used. Every claim you make is represented, not just your first one — the terms are shared out round-robin across your claims, so a long opening claim can no longer crowd out the evidence for the ones after it — and matching is whole-word, so "act" no longer matches "contract" or "actually". A run that names neither an audience nor an account draws exactly what it drew before, and repeat runs of the same draft still read the same quotes. Every existing guard is unchanged: no single company can fill a cohort's evidence, the same length band applies, and account quotes are still buyer-side only. eval_version moves 2.8.0 -> 2.9.0: a scoped run reads a different set of quotes on either side of that line, so do not pool scores across it.
  • Every workspace now runs the same agent surface. The platform had two code paths behind a per-workspace switch: the current one, and a pre-release one kept for workspaces that had not moved yet. Everyone is on the current path, so the old one is gone — one surface, one set of tools, one set of instructions. If you connect over MCP you get agents, search, enrich, lookalike and evals; that has been true for most workspaces for weeks and is now true for all of them. Two consequences worth knowing. A query that would silently return only part of its result is now always refused with an explanation rather than quietly trimmed — you will hear about a truncation instead of acting on a partial answer. And the tool list served by GET /api/mcp/api-keys/tools now matches what a session actually exposes; it previously advertised four tools that were not registered and omitted four that were.
Fixed
  • A read on a known scheme with an unserved path now names the valid URI shapes for that scheme instead of a bare not-found, and the dispatcher no longer silently routes an unmatched path to a different operation.
  • The eval docs described a score scale the grader cannot produce. Every rubric line is a binary pass/fail, so a per-dimension score is 1 or 5 and the headline is 1 + 4 × (passed / total) — over a five-line rubric that is exactly six values: 1, 1.8, 2.6, 3.4, 4.2, 5. Published examples showed dimensions at 2 and 4 and headlines of 1.6, 2.4, 4.4 and 4.6, none of which any run can return. The worked example, the annotated response samples and the run-folder templates now all read on the shipped ladder, and state the arithmetic once: 1.8 means exactly one line passed. Read checks_passed / checks_total — the fraction says what the number measured.
  • The two grading-loop skills (ground-and-draft, grade-and-report) are now published in the public Amdahl cookbook. The guide previously linked them to a repo only Amdahl employees can open, so the documented path to them returned a 404 for every customer. A docs check now resolves every github.com/amdahlco/* link a published page carries — the full path, not just the repo root — so this class cannot ship again.
  • ground-and-draft's ground.sh now takes an optional filters argument, so the evidence pull can be scoped the same way the grade will be — buyer-side only (speaker_type), or one account (company_id). It also validates that argument before spending a request, instead of burning four retries on a filter the semantic lane refuses. A cold account correctly has nothing narrower than its cohort, and the skill now says so.
  • Chat answers no longer skip the <unverified> check on a figure that happens to touch a letter. A scale word ($450MM), an ordinal (500th), a unit (250bps, 450MWh, 250000USD) or a missing space (1,240deals) is a number the grounding gate should be checking, not an identifier — only a run id, a uuid, or a snake_case handle is skipped now. Run ids and backticked ids are still never marked.
  • A stray code fence or an unmatched backtick in an answer no longer silently switches the grounding check off for the rest of that answer. An unclosed fence and an unbalanced backtick now suppress nothing, rather than everything after them.
  • A read 404 now returns the same body whether the row was missing or the id was malformed, so a mistyped identifier gets the valid-URI-shape correction it previously never received. The shape list is deduped to one entry per URI shape (query-parameter spellings of the same path are no longer listed separately), and no longer truncates — knowledge_base://content/<id> was being dropped from the correction that exists to point at it.
  • Evals: the workspace-wide quote pool no longer varies run to run — and the ranking behind it is now stable by construction. The previous entry flagged this as not fixed, on the reading that the workspace-wide pool drifted for reasons of its own. Re-measuring against stored runs showed that was the wrong read: those pools were tracking the same per-run reading of your draft that the shared-reading fix had just pinned down, so agreeing the reading once already settles them. Across 129 stored runs, no two runs that issued the same retrieval searches drew a different pool inside 43 minutes; the one case that differed at all was 44 minutes apart, which is your data genuinely having changed. The distinction matters and is easy to get backwards: two runs can start from the same short list of keywords and still search differently, because the keywords are a compressed form of a longer reading of your draft. Runs grouped by the keywords alone DO still show different pools seconds apart - that is the same behaviour the shared-reading fix addresses, not a separate one. Separately, and this one is a real fix: theme ranking could leave two equally-relevant themes in an arbitrary order, because nothing decided ties. Two themes tie whenever they contain an identical piece of conversation, which happens on live data — and a tie landing at the top of the list is exactly where it changes which quotes you are graded against. Ties now resolve the same way every time, in theme search and in theme-by-facet browsing alike. eval_version moves 2.10.0 -> 2.11.0. A tie is the only thing this can change, so every other query grades exactly as before; take the version bump as the honest marker that a boundary may have moved rather than as a sign your scores were wrong.
  • Evals: two runs of the same draft now read the same customer quotes. When you name an audience or an account, the quotes you are graded against are ranked on the claims your draft makes — but the reading of those claims was being re-derived per server, so the same submission could land a different reading, different terms, and a different set of quotes on each run. Measured on live data, one draft drew three different cohort quote sets across six runs seconds apart: two of the six quotes moved, on the only quotes allowed to back a claim about that cohort. That reading is now agreed once and shared, so repeat runs of one submission rank on identical terms and draw an identical cohort set — which is what makes an A/B between two drafts, or an N-sample of the judge, mean anything. Each run's progress trail now says which layer produced the reading, so a future disagreement is attributable rather than a mystery. Not fixed, and worth knowing: the workspace-wide quote pool still varies run to run on identical input — a separate, older behaviour that this does not address. eval_version moves 2.9.0 -> 2.10.0. Every individual grade means exactly what it did before, but a repeated measurement of ONE submission no longer re-samples the evidence, so do not pool a multi-run reading taken before that line with one taken after.
  • Evals: the cohort-evidence rule you are graded against is now written down in full. When a run is scoped to an audience, the quotes it draws are tagged segment and license a claim about that cohort. The rule shown to the grader named one edge of that — do not stretch a cohort quote into a claim about the recipient personally — while the grader was in fact enforcing two. The other edge is that a pattern several companies stated is not a single customer anecdote, so writing _"one lead told us…"_ off cohort evidence failed the same grounding check, for a reason nothing told you about. Measured on live runs, a draft doing exactly that failed 3 times in 6 and passed once, which is a rule you could trip without ever being able to read it. Both edges are now stated, and stated as a level to match rather than a list of banned phrases, so you can reason about a sentence neither example covers. No grading rule changed — what changed is that it is legible to the person being graded. eval_version moves 2.11.0 -> 2.12.0.
  • Chat: the resume_url handle returned by POST /chat now points at the public agents.resume route, so answering a paused turn works for workspace and MCP keys (it previously returned a 403 on the documented answer path).
  • Chat: a quick-depth chat now stays quick across follow-up turns instead of re-opening the full toolset when a follow-up omits the depth setting.
  • Search (search.query fuzzy lane): detail.groups[0].internal is now the result object on REST, matching MCP and the docs (it was serialized as the string "[Circular]").
  • Search: escalate_reason is now populated whenever escalate_to_chat is true, naming the sub-question the fast lane could not answer.
  • MCP prompts: system/guide_me, system/getting_started, and system/research_playbook are now readable via prompts/get for every workspace key (they were listed but returned a scope error).
  • Evals: GET /eval-runs now accepts run_status as an alias for status, so the MCP and REST filter names are interchangeable.
Added
  • Read an eval result over MCP. The evals tool gained status, list_runs, list and get. Firing a graded run returns a run id, and until now reading that run back needed an MCP client that implements resources — so a tools-only client could start a grade and never see it. status takes the run id from run and returns the verdict.
  • Wait for a grade instead of polling for it. GET /eval-runs/:id and the evals tool's status action accept wait_ms (up to 30 seconds): the call blocks until the run finishes and returns the same body an immediate read would. If the budget runs out first you get the run back with its current status, which is an answer, not an error.
  • search.query (mode fuzzy) now reports its own coverage. The response carries uncovered — one entry per part of your question that produced no answer, each with the reason (breadth_cap when the question had more independent parts than the lane answers in one call, deadline when a part ran out of time, planner_incomplete when the question could not be split in time) — and the plain-language message names those parts so you know what to ask again. Previously an answer covering three of five parts was indistinguishable from one covering everything.
  • search.query responses carry a timing breakdown (plan_ms, fan_out_ms, coverage_ms, total_ms, plus per-sub-question timings on the fuzzy lane's detail), so a slow call can be attributed without guesswork.
  • search.query can run as a background job. Pass async: true and you get a job_id back immediately instead of waiting; call the verb again with just that job_id to collect the result. A job gets minutes rather than the ~15s a blocking call allows, so a deliberately broad, multi-part question can be answered in full rather than in part.
  • search.query takes max_subqueries, so you can ask for a wider split when a question has more independent parts than the default 3. Up to 5 synchronously, and up to 12 on a job.
  • Every search.query reply now carries retry_guidance: when part of your question went unanswered it names the exact parameters to re-send (raise_max_subqueries, run_async, or narrow_query) rather than leaving you to work out the remedy from a flag.
  • You can now grade two drafts against the same customer quotes. Pass evidence_from_run: "<run_id>" to evals.run and the new run reuses the quotes that run was graded against, instead of retrieving its own. Without it, every run retrieves fresh evidence seeded from what you submitted — so editing a draft and re-grading changed both the copy _and_ the quotes, and the score difference mixed the two. On a real pair we measured, two runs of the same email shared none of their four retrieval queries and only 5 of 12 recorded quotes. Pin the second run to the first and the difference is attributable to your edit.
  • A new comparison read says whether a score difference means anything. GET /eval-runs/{id}/compare/{other_id} (eval_run://<id>/compare/<other_id>) returns both verdicts, how much customer evidence the two runs actually shared, and — only when they were graded on the same basis — the score delta. When they were not, it withholds the delta, says how far apart the evidence was, and tells you which run to pin to. A number next to a warning still gets quoted, so it does not ship one.
  • Reports now say what their evidence was fixed to. grader_meta carries a new evidence_scope: either {"kind":"run"} (retrieved for this run and held fixed across its revision rounds) or {"kind":"pinned", ...} with the run it came from and when those quotes were frozen. The old evidence_frozen: true only ever meant the first of those, but read as "held fixed for this comparison" — it is still present for compatibility and is now deprecated. A pinned report also carries evidence_provenance.pinned_from, so you can see you are grading against an older view of your data rather than today's.
  • The run list now says which runs reused another run's evidence. Rows from GET /eval-runs (eval_run://list) carry evidence_pinned_from: the id of the run whose quotes they were graded against, or null when the run retrieved its own. It is the pin ORIGIN only, never the quote pool, so the list stays lean; a run history can mark an A/B pair without a detail read per row.
  • Evals: a report now names what the rewrite made worse. improvement.regressions[] lists every rubric dimension the improved version scored LOWER than your original, with both scores, the judge's own reasoning, and flipped_to_fail when the binary verdict flipped. The headline lift is a mean, so a rewrite could gain three points on grounding, drop positioning from 5/5 to 1/5, and still report a rise with nothing on the wire saying so. Absent, not empty, when the rewrite cost nothing. what_changed now names the trade alongside the gain.
  • Evals: the overall reasoning no longer asserts the opposite of the finding. A case's headline verdict follows the IMPROVED side, so on a run where your draft held up and the rewrite did not, the summary paragraph opened "The message does not hold up against the evidence" - a sentence about writing you did not submit. Both disagreeing directions are now named explicitly.
  • Evals: the run-comparison caveat no longer claims the delta measures your edit. Grading two runs against the same customer evidence establishes that the difference is not explained by different quotes; it does not make the score difference an effect of what you changed, because overall_score blends graders judging two different artifacts. The caveat says what was actually established.
  • Docs: a new "What the score is, and what it is not" section states the three properties that hold on every run - the headline score is mostly about the rewrite, grounding penalizes abstract copy on any corpus size, and the number is a coaching read rather than a measurement. The cases[] reference now documents input_passed / improved_passed / transition instead of describing passed as "every applicable grader passed", which stopped being true when the verdict split shipped.
  • Eval report quotes now say who said them, when, and at what deal stage. A cited quote can carry speaker_side (a customer, or your own team), and account-tier quotes additionally carry spoken_at, the speaker's role_level, and deal_stage_at_time — the stage the deal was in when the words were said, not the stage it is in now. All four are read from the warehouse row the quote was retrieved from, so the grader cannot assert any of them, and any field the warehouse could not answer for is simply absent rather than filled with a placeholder. Speaker names are deliberately not surfaced. Worth knowing: corpus-wide retrieval does not filter on speaker, so speaker_side is what tells you when a quote is one of your own reps rather than a customer.
  • An eval run now comes back with a link to its report. evals.run responses carry a console_url pointing at that run in your workspace console. Previously the reply held run_id and eval-run://<id> — both addresses only a machine can follow — so firing an eval from an MCP client (Claude Desktop, Cursor) meant copying an id and hunting for the run in the console to read what you had just paid for. The link works the moment the run is queued: it shows the grading trail live, then the report. It is omitted rather than guessed when it cannot be resolved, so an absent field means "no link", never a broken one.
  • The grade of your own copy is now one field, not nine levels deep. run.verdict.headline states the submitted and the improved grade side by side and labelled — submitted.score_15, submitted.checks, improved, the threshold, the transition, the lift and its noise gate, and the confidence read. Until now the only number at the top of a verdict was overall_score, which is a mean over graders judging two different artifacts — your draft AND the rewrite the eval produced — so it was never a grade on your writing, and a run that returned overall_score: 1.0 for a draft clearing 2 of 5 checks was reporting both correctly. Your grade was there all along, at cases[0].graders[1].improvement.facets[0].before.score_15. Now it is where the number that gets quoted actually lives. It is the SAME object the report card renders from, so a script and a person cannot read different numbers. overall_score is unchanged.
  • The run list carries your grade too. verdict_summary on GET /eval-runs adds input_score_15 / input_score and improved_score_15, so a run history shows how each draft scored without a detail read per row. The same two numbers are on each case as input_score_15 / input_score.
  • Comparing two runs now reports how far YOUR copy moved. GET /eval-runs/{id}/compare/{other_id} adds submitted_score_delta and submitted_score_15_delta beside the existing score_delta, which differences overall_score and so blends your draft with the eval's rewrite. Both operands ship on each side, so the subtraction is auditable — and both grades stay visible even when the delta is withheld. Comparisons of runs graded before this shipped get the new fields too. They are held to the same bar as score_delta, including the noise floor: a draft that moved by a single rubric line is inside this judge's measured run-to-run spread, so that comparison reports delta_withheld_reason: "inside_noise_floor" rather than a number.
  • A grade on a draft you never wrote now says so. When you submit a prompt and no message, the eval writes a specimen so there is something to score. input_simulated: true marks it on the case, the headline and the run list — previously input_passed: false there read as a verdict on your writing.
  • Reports carry the confidence caveat, not just the level. improvement.confidence.caveat is the one-line reader-facing note for the level, present whenever the level is below high.
  • Eval reports now flag rubric dimensions that the kind of writing you sent could never satisfy, and accept an optional artifact_type saying what you sent (outreach / landing_copy / objection_response / nudge). Website and landing copy is the case this exists for: the message rubric leads with "is the offer positioned for THIS customer's specific situation", and copy addressed to a market has no such customer, so it failed that line on every run with nothing on the payload saying why — and three of the five prompt-rubric lines have the same problem. A flagged dimension now carries not_applicable, a reason, and not_applicable_source (declared_type for a deterministic consequence of what you declared, judge for the grader's own reading). The flag does not move any score yetpass, score_15, passed/total, the transition and overall_score are all still taken over the full rubric. What ships beside them is what the number would be over the applicable lines only (applicable_passed, applicable_total, score_15_applicable, score_applicable, absent when every dimension applied), so the size of that change is measurable on real runs before the headline moves. Every run also reports the artifact type it resolved to and where that came from; an inferred reading is a label only — it does not pick a rubric, exclude a dimension, or reach the grader, and comparing two runs that resolved to different types now refuses the delta rather than reporting a difference between two different denominators.
  • GET /eval-runs/{id}/report returns an eval run already written up — a markdown card to paste, plus the same numbers machine-readable on headline. It exists because the eval grades two different artifacts (your draft, and a rewrite it produced), so a caller who summarises a run in their own words is exactly where the rewrite's score becomes a claim about your copy: a live run carrying overall_score: 1.0 describes a draft that passed 2 of 5 checks. The card states both sides with their pass fractions, prints a lift only when it clears the measured noise floor (below it, "no measurable change" rather than a small confident number), flags a "before" the grader had to simulate on a prompt-only run, and carries the confidence caveat. It renders on every run state, so a failure or an abstain gets an honest card rather than an empty body. The two skills the grading-loop guide already named — ground-and-draft and grade-and-report — now ship with Amdahl in skills/, with scripts for grading, A/B-ing two drafts on frozen evidence, and taking a median of N. The A/B path reads the submitted side: on a pinned pair whose true draft delta was −0.8, compare.score_delta reported −0.167.
Changed
  • Eval scores stay comparable when we upgrade models. The model that grades your evals is now pinned separately from the model the rest of the product uses, so a platform-wide model upgrade no longer changes the scale your results were measured on. The model that writes the improved prompt and message still tracks the latest available, since that is the part you receive. Every report continues to name both under grader_meta.blinding.
  • A model change now re-runs an eval instead of reusing the cached result. evals.run with reuse: "cached" previously returned a stored run whenever the eval, tenant and inputs matched, even if a different model had graded it — so after a model upgrade you could be handed a verdict the previous model produced. Which models graded a run is now part of what identifies it. Expect a one-time effect when this ships: the first call for an eval you have run before is graded fresh rather than served from cache, and costs a full run. Repeat calls reuse as before.
  • Whether a run pinned its evidence is now part of what identifies it. evals.run with reuse: "cached" previously matched on the eval, tenant, inputs and models — so a pinned request and an unpinned one with the same message resolved to the same stored run, and you could ask to grade against a specific run's quotes and be handed one that had retrieved its own. Expect a one-time effect when this ships: the first call for an eval you have run before is graded fresh rather than served from cache, and costs a full run. Repeat calls reuse as before.
  • Polling a run no longer returns the full quote pool. GET /eval-runs/{id} omits the recorded evidence set, which can run to tens of kilobytes on a surface you poll while a run is in flight. The quotes the judge actually cited are unchanged and still on the report; the full pool is available through the comparison endpoint, which is what needs it.
  • Eval reports now retrieve as much customer evidence as the question needs, instead of a fixed 16 quotes. Every graded report showed a pool of exactly 16 quotes, on every workspace and every question - the search reliably found far more and the merge threw the rest away. Retrieval now runs in rounds: it searches your conversations with a first batch of queries drawn from your submission, and goes back for the rest of them while the pool is still thin. It stops when the pool is deep enough, when a round returns nothing it does not already hold, when the retrieval budget is spent, or when your workspace has no themes at all. Runs are no slower - the same retrieval budget is now spent across the rounds rather than on one - and a workspace with a thick corpus still finishes in a single round.
  • Reports state the two evidence budgets separately. evidence_provenance gains in_context (how many of the pool were placed in the judge's context for a single graded call, which stays bounded so a deep pool cannot push one call past its token ceiling) and rounds (how many retrieval rounds it took - consistently more than one is a signal your corpus is thin for the questions being asked). pool keeps its exact meaning: everything retrieved and frozen. When the pool is deeper than the prompt budget, the quotes that reach the judge are sampled evenly across the search queries, so a wider search buys broader coverage of your draft's claims rather than more of whatever the first query matched.
  • `passed` no longer means two different things in one payload. On a graded facet it was an integer COUNT of rubric lines cleared; on a case, a grader and a dimension it is a boolean VERDICT — and a dimension carried neither, it carried pass. A consumer walking the report and reaching for the obvious key on a dimension got nothing back and rendered every dimension as a failure. Both spellings are now unambiguous and both ship: counts are checks_passed / checks_total on a facet (the older passed / total remain, carrying identical values, and are deprecated), and a dimension answers to passed as well as pass. Nothing was removed, so existing readers are unaffected.
  • Eval runs are now honest about being a single measurement. Three changes, all visible on the wire. Comparing two runs withholds a difference that is inside the judge's own noise. Controlling the evidence proves the two runs saw the same quotes; it never proved the judge would return the same numbers twice on them, and the measured run-to-run spread on byte-identical input is larger than the deltas that were shipping as results. compare now reports delta_withheld_reason (evidence_not_controlled · stop_reason_mismatch · not_scored · inside_noise_floor) and noise_floor, each with the remedy that actually fits — for a delta inside the floor that is more samples, not more control. It also refuses a comparison whose two improvement loops stopped for different reasons, since those runs did different amounts of work. The reported grade is now the first revision round, not the best one (transition.reported_round). How many rounds fit was partly a function of how fast the host was, so reporting the best of them meant the same submission scored higher on a faster machine; the revision rounds still run and iterations still reports them, they just no longer move the number. `grader_meta.blinding.sampling` reports what each stage actually sampled at — the configured temperature: 0 does not reach the current judge or generator models, which reject the parameter, so those calls run at the API default. That was true before and simply undisclosed.
Fixed
  • Broad, multi-part questions on search.query (mode fuzzy) no longer come back as a bare failure. A question with several independent parts could exceed the lane's internal budget and return failed with escalate_to_chat, discarding the sub-questions that had already been answered — so narrowing the question worked while asking it in full did not. The lane now finishes inside its budget, and a run that cannot cover everything returns the parts it did answer.
  • A broad, multi-part question on search.query (mode fuzzy) is now actually split into sub-questions. The step that decomposes your question had a 2s budget that was too small for a question with several parts, so it gave up and ran the whole thing as ONE query — which could match a single clause and return that as if it answered everything. The budget now fits the broad case.
  • When the split genuinely cannot be done, the answer says so instead of reporting full coverage. uncovered carries a planner_incomplete entry and the message warns that the result may cover only part of what you asked.
  • Messages to accounts you are already working with are graded again. The eval that grades your writing was declining to grade a large share of real sales email. Its check for "is this commercial writing" had, in practice, learned to mean _cold first-touch_, so chasing an unsigned order form, pushing a proposal, or negotiating whether a pilot converts came back not_applicable instead of a grade. Measured against real seller email, about a quarter of everything it declined should have been graded, and 93% of those were accounts already in play. The check now asks whether the message had a persuasive job — at any stage of the relationship — and still declines correspondence that had none, such as scheduling, receipts and support replies, including when they sit inside an active deal.
  • A declined run no longer blames your data. When the eval could not grade something, the summary said "the required data surface was unavailable" regardless of the real reason — so a submission that was simply not the kind of writing the eval reads sent you to check your data connection. Each reason now says what actually happened and what to do about it, and a run that declined to grade says plainly that this is not a judgement on your writing.
  • search.query now tells you to run a broad question asynchronously when parts of it ran out of time, instead of suggesting a wider split. A sub-question that runs and hits its budget is a timing problem, and widening the split adds more parallel work to a search that was already too slow — so the advice made things worse. It also no longer reports full coverage when sub-questions were cut.
  • MCP clients no longer need to reconnect to use a newly-added tool parameter. A client caches the tool schema when it connects, so a session that was open before a parameter shipped sends that parameter as text — and the server used to reject it on type alone. Scalar parameters on the search tool now accept their string spelling, so an additive change works on already-open sessions.
  • Every numeric and true/false parameter on the MCP tools now accepts its text form. A client caches the tool definitions when it connects, so a session that was open before a parameter shipped sends that parameter as text and the server used to reject it — meaning you had to reconnect to use an addition. Additive parameters now work on already-open sessions.
  • External search is less likely to run past its time limit and return nothing. Three stages inside its latency budget were bounded per network attempt rather than per call, so a slow stage could cost roughly three times its stated budget — in one case more than the entire ceiling on its own. Each now costs what it says.
  • A topic enrichment with no matching customer conversations no longer reports a divergence-map error. The divergence map compares what the market says against what your customers say, so when you have no conversations on a topic there is nothing to compare — the map now comes back empty with the reason attached (not_applicable_reason: "no_internal_evidence") instead of an error, and the call skips the slowest synthesis step entirely, so it returns sooner.
  • The external_search divergence map no longer comes back empty at random. Sonnet 5 runs extended thinking by default and thinking tokens are charged against max_tokens, so a pane declaring a 1000-token budget was splitting it between a think and an answer — and when the think ran long the model returned no text at all, on a successful response. The panes now apply the existing thinking guard, as does the shared llmComplete seam. A pane that exhausts its budget also says so rather than reporting the same message as a model that returned nothing, and its log records which content blocks came back. LLM token counts are no longer redacted from logs as if they were auth tokens.
  • Extended thinking can no longer silently consume an LLM call's whole token budget. Sonnet 5 and Opus 5 think by default and thinking tokens count against max_tokens, so a call with a tight budget could return a thinking block and no answer at all — on a successful response, which callers read as an empty result rather than an error. The shared Anthropic client now applies the existing thinking guard to every request, so the roughly 48 direct call sites are covered without each having to remember. A caller that asks for thinking explicitly, or whose budget can afford it, is unaffected.
  • Eval runs now cost the time they say they cost. The wall-clock budgets on the evals LLM stages were being applied per network attempt rather than to the stage as a whole, and the client retries twice by default, so a stage declaring a 420-second budget could run for roughly 42 minutes and be killed by the run reaper instead of finishing or failing cleanly. Each stage now shares one deadline across its retries. The industry angle planner had the same problem and is fixed with it.
  • Pasting source code into an eval no longer produces a sales-message grade. Submitting a bare function, a JSON blob or a stack trace returned a full graded report that read "your submitted draft did not hold up against the evidence" - the eval had written an improved _sales message_ out of a sorting routine. A submission that is entirely code, configuration or a stack trace is now turned away as not applicable, before anything is spent on it, with a reason that says what was read. A refusal is not a failing grade: nothing was scored. A message that merely quotes a snippet, a config value or an ASCII arrow is still graded normally - that is ordinary technical-sales writing, and the check only fires when the whole submission is code.
  • `POST /evals/run` now reports a bad input the same way whichever check catches it. The endpoint validated its request twice, and only one of those checks spoke a language a caller could act on. A non-string on a text field ({"inputs": {"message": {"body": "hi"}}}) came back as a serialized internal validator error — a nested unionErrors array of per-branch failures under a top-level message of just "Invalid input" — from which you had to work out that message wanted a string. A malformed inputs came back as a clean "$.inputs: must be object, got array". Same endpoint, two vocabularies. Now a wrong type on a declared field is refused by the eval's own input schema, which names the field and the type that arrived, on details.input_errors; anything the request shape itself rejects renders as a path-addressed line on details.errors. No response carries validator internals. Separately, a number field stops coercing non-numbers, so an array is refused rather than quietly becoming a figure the run would grade against.
  • MCP: an assistant can now actually read the eval result it just asked for. The connected-agent instructions said to poll eval_run://<id>. Underscores are illegal in a URI scheme, so standards-compliant clients rejected that address outright (-32603 Invalid URL) — the resource is published as eval-run://<id>. An assistant that followed its own instructions could fire a graded run and then had no documented way to collect the report, which is a large part of why models narrated a conclusion instead of showing the before/after. Five more cited addresses carried the identical defect (search_field://, and agent_blueprint:// / blueprint_run:// / blueprint_schedule:// / content_piece:// on the pre-agent-v2 surface), plus one that named a scheme with no path at all (notification_sends://, now notification-sends://list). All corrected to the spelling the server publishes.
  • MCP: the instructions now say which side the headline eval score is about. They already explained how to read usage and that a failing draft is the finding, but never that overall_score averages graders judging TWO different artifacts — the hygiene rules score the text you submitted, improvement_loop scores the improved version the eval wrote — and that the case verdict follows the improved side. An assistant reading a pass could tell someone their draft was fine when the report said it was unusable. It is now told to quote improvement.transition.input_verdict for the user's own writing and never overall_score.
  • Evals: the tool description now describes the eval that actually runs. It advertised a judge scoring "grounding/specificity/tone" — there has never been a tone dimension, so an assistant reading it looked for a score that is not on the wire and could not discover the five that are (relevant positioning, grounding, verified specifics, differentiation, CTA clarity). It also omitted the account and mode inputs entirely, making account scoping and advisory-vs-rewrite unreachable for anyone reading only the tool surface, and still offered an empty-inputs regression eval that was retired — omitting inputs is now a validation error, not a second mode. It also now says your prompt is graded on its own separate rubric rather than sharing a score with the message.
  • A refused eval run no longer reports a score of zero. When nothing could be graded — a customer-evidence outage, an engine failure, or copy the rubric declines — overall_score now comes back null with a top-level not_applicable_reason naming the cause, on both the run detail and the run-list summary, so a refusal is distinguishable from a bad grade without reading the prose. The refused case also no longer carries input_passed / improved_passed / transition: those answer "did your copy clear the bar", and a refusal never asked the question — previously a run that graded nothing shipped a hard 0 beside input_passed: true, which read as a terrible score and a pass at the same time and quietly dragged down anything tracking the number over time. If you poll `overall_score`, handle `null` — drop refused runs from an average rather than scoring them.
  • The audience input on prompt-and-message-eval now changes the EVIDENCE, not just the framing. It previously reached the judge as an instruction to assume a cohort while retrieval stayed corpus-wide, so a run scoped to "VP of Marketing" and an unscoped run graded an identical pool. A resolved cohort now gets its own retrieval leg — that cohort's own utterances, drawn across several companies so one talkative account cannot stand in for the group — tagged segment, the tier that licenses "teams like yours". This was the missing rung: the tier existed in the schema and the grading prompt licensed it, but nothing ever produced it, so segment-level copy had no tier that could back it and either failed grounding as an over-claim or had to be flattened into a general market observation. The remediation the report handed you was unactionable. Account-tier evidence stopped silently for large accounts. The quote lookup did not declare its own row bound, so the query engine refused it outright whenever an account had more than 100 qualifying conversations — the biggest accounts, the ones you are most likely to name — and the report abstained with a lookup failure while quietly grading against corpus themes. Under that threshold the same defect returned up to 100 account quotes instead of the intended 10, which filled the judge's whole context with one tier. Both are fixed, and account and cohort limits are now sized together so a corpus quote always reaches the judge. A run that both pinned another run's evidence and named an account is now refused instead of silently resolved in the pin's favour. It used to retrieve the named account's quotes, discard them, and inherit the pinned run's — which carry an account tier and a different account's name, so the report headed a section with one company over another company's words and handed the judge an account-tier licence for a claim that company never made. The two requests are genuinely exclusive: pinning holds the evidence fixed so a score delta is about your copy, naming an account retrieves what that account said. The run says so and names both ways out. The same check covers the cohort. Tier coverage is now queryable rather than prose-only: account_status, account_quotes, segment_status, segment_quotes, segment_companies, corpus_quotes, and corpus_only ride on every improvement report, and the run's progress trail emits its account and segment steps on every run — including one that scoped to neither, because "this run stood on no account evidence" is a finding about what the copy was allowed to claim. Pinned reports also keep the quote provenance (speaker_side, spoken_at, role_level, deal_stage_at_time) they previously dropped on the way into storage, so a pinned report no longer looks less attributed than the run it pinned.
  • An MCP session you are using no longer expires. Sessions used to be dropped two hours after they were opened, no matter how active they were, so a long working session was guaranteed to break mid-task. The two-hour budget is now measured from your last call rather than your first: keep using a session and it stays alive indefinitely, and it is only reclaimed after two hours of no calls at all.
  • A busy session is no longer the first one dropped under load. When a server was at its session limit it made room by dropping the session that had been open longest — which, during a burst of new connections, meant preferentially killing the sessions doing real work. It now drops the session nobody has called in the longest time, so an in-use session is never an eviction candidate.
  • `-32000` now tells you whether it was us. The "session not found" error carries a data block with server_uptime_seconds. If that is smaller than the age of the session you were holding, the server restarted and your session could not have survived it — so a deploy is distinguishable from a bug in your client without guessing. The error also echoes your JSON-RPC request id instead of always returning null.
  • The retry contract is published. Reliability and retries documents how long a session lasts, what ends one, and the single rule for -32000: reinitialize and replay the call once — not a backoff loop, which cannot help when the session is already gone.
  • The Evals documentation now describes the eval you actually get. Eight things on the published pages had fallen behind the code: mode was documented as defaulting to rewrite when the default is advisory; the hygiene length cap was still the withdrawn 1400-character bar rather than the 3000-character outlier guard that replaced it; four sample payloads carried "eval_version": "1.2.0" when the served eval is 2.5.0 — a value that rides the run fingerprint, so anyone building cache or comparison logic against it was coding to a string that never appears; the API reference described a case's passed as "whether every applicable grader passed" when it follows the improved side, so a run could read pass while your own draft was unusable; it also said retrieved quotes are never the recipient's, which stops being true the moment you pass an account that is in your data; the run handle's resource was spelled eval_run:// when the server returns eval-run:// (an underscore is illegal in a URI scheme, so the underscore form throws in a standards-compliant MCP client); two copy-pasteable "run the regression harness" examples were published that the server refuses with invalid_argument, because no built-in eval ships in generated mode; and the eval builder's rules still named the retired search.run instead of search.query. The evidence-tier tables now also say plainly that segment is part of the wire vocabulary but is not emitted today, so its absence is not a statement about your data.
Breaking
  • `POST /search` is removed. The plain-language fast lane now lives on the routed endpoint: post the same question to POST /search/query with "mode": "fuzzy". You get the same planner, the same NL-to-SQL writer, the same multi-question split, and the same envelope — the rows are on results, the SQL it ran is on compiled.sql, and the full warehouse envelope you used to read at the top level (internal, groups, message, coverage, escalate_to_chat) is on detail. Scope is unchanged (data:read). On MCP the search tool's run action is gone the same way; use its query action.
  • The blended web + news pass is gone. mode: "blended", external_limit, and the external / external_omitted response fields no longer exist. Amdahl's synchronous search reads your own warehouse only. For market signal, use external_search (which also gives you the internal-vs-market divergence map the fast lane never had); for a written answer that weaves both, use Chat.
  • The `synthesize` headline is gone. synthesize: true and the synthesis response field are removed. The search verbs return data, not prose — ask Chat when you want it written up.
  • Authored evals using a generated subject must set target.op to search.query instead of search.run, and the has_citations deterministic check is removed (it measured the retired blended leg). No shipped eval used either.
Changed
  • The graded report now shows its work. Reviewers kept asking the same questions of an eval report — what does "5/5" mean, what does "scored blind" actually check, and why do three different quote counts appear on one page — so every one of those is now answered on the report itself rather than taken on trust. What the score counts: each graded side carries passed / total beside score_15, because that is what the score is — the judge returns a pass or a fail per rubric line and the headline is that fraction placed on a five-point scale, so a 5 means "cleared all five checks", not "perfect". How much it is worth: a new confidence (high / moderate / low, with the reasons) flags a grade backed by thin evidence, an errored retrieval leg, a truncated prompt, or a run that stopped on a budget rather than the bar. It never changes a score; it tells you how firmly to read one. What "blind" recorded: grader_meta.blinding carries the facts behind the badge — both candidates scored in one call against one rubric and one evidence set, presentation order taken from a hash of the text _you_ sent, which way that landed on this run, and whether writing and scoring were separate calls. Where the quote numbers come from: grader_meta.evidence_provenance labels the three counts that used to sit unlabelled in three places — retrieved, cited, and resolved — so a page showing 16, 3 and 6 reads as three different true things instead of a contradiction.
  • Point an eval at the account you are writing to. prompt-and-message-eval accepts a new account input. When that company is in your conversation data, their own buyer-side words are retrieved and tagged account — the only evidence that licenses an account-specific claim — and the improved message can say "you told us" and mean it. Every quote now carries a tier (account / segment / corpus) that bounds what a claim built on it may say, and reaching past a quote's tier is graded as a grounding failure rather than a style note. When the account is not in your data the run says so, and which way it was missing (never named, could not be resolved, no conversations, or our lookup broke) — it does not quietly grade you on cohort evidence under an account heading.
  • The prompt edits now come as one block you can paste. The prompt half of the report is the half that compounds — a fixed message helps one send, a fixed prompt helps every send after it — so the prompt suggestions are now also assembled into a single copyable prompt_patch. It is composed from the same itemized suggestions, so it can never claim an edit the list does not, and the report renders one or the other rather than saying every edit twice.
  • prompt-and-message-eval (now version 2.5.0) no longer fails a drafted message for being longer than 1,400 characters. The basic-hygiene upper bound is now 3,000 characters and is documented as an outlier guard — "this is a document, not an email" — rather than a style bar. The old bound was an unmeasured opinion: tested against 127,872 labelled outbound emails across four workspaces, the relationship between message length and getting a reply reverses sign between workspaces once you compare a sender against themselves. In one, cold openers over 1,400 characters replied 32 points _better_; in two others they replied 5-7 points worse. At the old bound the check was failing 8-24% of the messages those teams actually send, and in the first workspace it was failing the ones that performed best. Because runs are content-addressed over the eval version, previously graded messages re-grade on their next run rather than serving the old verdict. The lower bound (30 characters) is unchanged and is supported: messages below it are 0.3-0.6% of real sends. If you want a tighter house style bar, fork the eval and set your own length check.
Removed
  • POST /search (search.run) and the run action on the MCP search tool.
  • The standalone Search guide, folded into the Search endpoint guide.
Fixed
  • External search now degrades instead of going dark when the search provider is unavailable, and it tells you which happened. A provider outage used to come back as sources_timed_out, so a blended search or an enrich brief returned zero citations with no way to tell an outage from a slow source — reading, incorrectly, like the product had no web access at all. Two changes: an unusable provider now falls back to the secondary web-search backend rather than losing the source, and the blended result carries external.source_failures[] ({ source, reason }, where reason is error for an outage vs source_timeout / deadline for slowness). sources_timed_out is unchanged and still lists every source that did not complete.
  • The grader is now told who you are writing to. The message rubric grades your ask on whether it suits the audience, and the judge was never given the audience — it was passed to the half of the run that WRITES the improved message but not to the half that SCORES it, on every run, while the report still said an audience had been resolved. It now reaches both, rendered identically, so that dimension is scored against a real referent. When no audience could be scoped, the judge is told that explicitly rather than left to guess one from whichever draft happened to name a cohort.
  • A hygiene check no longer fails your message for phrasing its ask in an unusual way. The call-to-action check matched about a dozen stock phrasings and marked everything else as having no ask — which failed the whole basic-hygiene grader on roughly half of real seller emails, over asks as plain as "Register for the Feb 19 webinar" or "Move forward with signature to close before quarter end". Measured against a frozen corpus of 753 real messages, it wrongly failed 389 of them. A word list can show an ask is there; it can never show one is absent, so a miss now ABSTAINS instead of counting against you, and the grader scores over the checks that actually decided. On that same corpus the check now decides positively on 601, abstains on 152, and fails none; the hygiene grader as a whole goes from 109 passing to 340. Nothing was loosened — a banned buzzword or a length violation still fails exactly as before.
  • Schema guidance for sentiment_primary and buyer_pushback now reflects the 2026-07 objection-recall pass. The neutral rate is reported per word-count band instead of one corpus-wide figure, the recall improvement is scoped to objection on turns of 10+ words (other labels and shorter turns are unchanged), and subject-precision figures are restated against the current population.
Added
  • Salesforce email comms filter. The SOFT CRM comms filter now supports Salesforce in addition to HubSpot: keep only external / rep-involved email (from the EmailMessage object) and drop internal-only messages before they're ingested. Configure it via connections.set_comms_filter or the comms_filter param on connections.connect; account, contact, and opportunity records are untouched.
  • The natural-language SQL helper (data.ask) can now identify and join on a specific conversation turn, not just the whole call. A new grouped_utterance_id column exposes each utterance/turn's own id — the same value previously reachable only under the confusingly call-scoped interaction_id name. Pairs with the existing sequence (turn order) and parent_interaction_id (call id) columns for turn-level analysis, e.g. tracking a seller's statement against the buyer's immediately-following response. Correction (2026-08-14): this column was originally described here as a "stable id". It is stable *within* a single read, but it is not durable across a pipeline regroup — regrouping re-mints the id for any turn whose group boundaries change. Join on it freely, but do not store it as a long-lived key in your own systems; derive it at read time, and use parent_interaction_id when you need an id that survives regrouping.
  • buyer_pushback / pushback_type on the interactions data surface. Two new columns are now queryable via data.query, data.ask, and MCP schema discovery, answering "did the buyer push back on adopting our solution" directly instead of the coarser objection sentiment label (which also fires when a speaker is describing their own business, not ours). buyer_pushback is a tri-state flag — NULL means not yet judged, FALSE means judged and not pushback, TRUE means judged pushback — and pushback_type names the kind (pricing, timing, proof burden, and others) when TRUE. Coverage is a narrow, growing slice of conversations, so a low or zero count means "not yet judged," not "no pushback" — pair it with cluster search or a content search when you need full coverage.
  • Evals is available in every workspace. Grading a prompt or a message against your own customer quotes no longer depends on which agent surfaces your workspace has turned on - the Evals page, the run history, and the REST and agent-tool surfaces are open to everyone. Authoring your own evals stays in limited beta; reach out if you want in.
  • Internal groundwork for validating the eval against human judgement. Platform staff can now label eval runs blind — per rubric dimension, with a pass / fail / can't-tell verdict and a one-line reason — and the resulting inter-rater agreement (Cohen's kappa per dimension) is computed against the human majority. This is the external anchor the grade has not had: until now the rubric was scored by a model and checked by a model, with nothing outside the system agreeing with it. Staff-only and non-tenant-facing. No change to how any workspace's runs are graded or reported.
  • Evals - grade + improve content against your own customer data. The flagship message-grader eval takes a prompt AND/OR a drafted outbound message (send one or both) and hands back a before→after report built around relevant positioning for the specific customer: it grades what you sent, then produces AND grades an improved, reusable prompt (a template that uses Amdahl to position, verify, and keep the messaging relevant to that customer - not generic, not just voice) + a relevantly positioned message - with an overall score and plain-language reasoning, per-dimension scores (relevant positioning, grounding, verified specifics, ...), verbatim customer quotes that back or contradict each side (retrieved from your data - the model can only cite them, never invent them), a score lift, and a one-line "what changed." Send a message and it also suggests a reusable prompt; send only a prompt and it simulates a draft to grade; the improved message is always a suggestion, never mandated. Every case declares a subject: provided (you pass in the content, like a message) or generated (the eval runs a question and grades the answer - the gtm-default regression harness). POST /evals/run over REST or the evals tool's run action over MCP validates your inputs against the eval's schema, starts the grading job, and returns a run id immediately; poll eval_run://<id> for the verdict - an overall score, a pass/partial/fail/not_applicable bucket, and a per-case, per-grader breakdown carrying the before→after improvement report. A workspace with no customer data yet comes back not_applicable, never a false fail. Six grader kinds ship (rule + improvement_loop over a message, evidence_judge for a grade-only pass, deterministic / sor_anchored / judge over a generated answer); browse them via grader_kind://list. Author your own eval - configure the inputs, cases, and graders - with evals.create / update / delete, and dry-run a draft with evals.validate. Runs are read-only (they grade your data, never change it) and grade current data (internal-only in v1). Scopes: evals:execute to run + evals:write to author (both editor + the customer-agent key bundle), evals:read to browse, validate, and poll (viewer). See the Evals guide.
Changed
  • A long prompt submitted to prompt-and-message-eval is now read in full. Previously the middle of a large document was dropped and the grader answered existence questions ("does this prompt require verification?") against a partial read, which could report a missing instruction the document actually contained. Every section is now indexed and the grade runs over a whole-document digest; coverage reports map_reduce with nothing omitted.
  • Prompt and Message Eval: mode=advisory (anchored suggestions against what you already have) is now the default; send mode=rewrite for a full improved prompt and message. The mode input field declares its default on the wire (input_schema.fields[].default) so run forms can preselect it.
  • Eval runs: a not-applicable verdict's overall_reasoning now states the actual reason (too-thin submission vs no customer data vs engine failure) and includes the abstaining grader's own explanation.
  • Eval run list: lean rows' verdict_summary now carries the primary case's transition / input_passed / improved_passed when graded, so run histories can show the fail-to-pass story without fetching full rows.
  • API keys are now accepted as Authorization: Bearer <key> in addition to X-API-Key — one key works on every transport (REST, MCP, and the team endpoints).
  • Self-serve API key creation (Settings → Developer) now offers a permission-bundle picker: any member can mint Read only or Customer agent (the default) keys; the elevated Internal agent / Full admin tiers require workspace admin.
  • Eval run progress steps now carry structured per-stage detail (quote counts, suggestion titles, per-dimension outcomes) so run timelines can show the work behind each stage.
  • Eval runs now show the work, not just the word count. Expanding a stage in a live run shows the claims it pulled out of your draft, the queries it searched with, and the retrieved quotes themselves with the same q-ids the finished report cites. The evidence pool also went from 20 candidates to 100, so the panel can show everything the grader had to choose from rather than only what it picked. What the grader reads is capped separately and unchanged, so a wider pool does not move your scores.
  • Evals in the agent + a clearer eval list. The in-app agent (chat) can now run a graded eval — grade a prompt and/or a drafted message against your workspace's own customer evidence and read the result back — without leaving the conversation; it can discover which evals exist and dry-run an eval definition, while authoring an eval stays on the Evals page. The eval read now also reports each eval's origin (builtin = Amdahl-shipped, tenant = authored by your workspace) so the console can label the list "By Amdahl" vs "By your team", and it always returns a valid case subject now, so the eval builder no longer fails to open an eval whose case had no configured subject.
  • The `audience` on a Prompt and Message Eval run is now evidence-gated. It used to be free text that reached the grader as prose, so a run could report confidently on "positioning for VPs of Engineering" whether or not your workspace had ever spoken to one. Now it resolves to a seniority and is checked against your own corpus: a cohort scopes the report only once it clears three floors (3+ distinct people, 25+ utterances, 2+ distinct companies — the last so a single talkative account cannot stand in for an audience). When it does not, the run still grades your prompt and message against your whole corpus and says which of five things happened, including telling you plainly when the check failed on our side rather than implying you have no data.
  • Suggested Amdahl calls are bounded by what your key can actually run. research_steps were already validated against the live operation registry; they are now also narrowed to the calls your own scopes cover, so a suggestion you would get a 403 for is never shown.
  • Authoring your own evals is temporarily limited. While evals are in beta, creating, editing, and deleting evals is restricted — over the API, MCP, and the console alike. Contact the Amdahl team to request access. Running the shipped eval, reading results, and validating a definition are unaffected.
  • Three rails so the report cannot assert more than it measured. A submission that is not commercial outreach is now refused rather than graded. The rubric scores craft — positioning, grounding, verified specifics, differentiation, the ask — and a short factual notice satisfies several of those for free by being concrete and having a clear next step, so it could out-score a real sales draft. The check rides on the intent call that already runs, and only a confident judgement refuses: an unsure or degraded read still grades. A prompt the system generated for you is no longer given a score when there is nothing to compare it against. The text still ships — on a message-only run the reusable prompt is the point — but grading our own draft told you nothing and returned full marks almost every time. A lift smaller than the instrument's own measured run-to-run spread is now flagged as not reportable, so it can render as "no measurable change" instead of as a confident figure. The raw value is still carried for analysis.
  • Eval grading now runs at temperature 0. Every LLM call an eval made was sampled at the provider default of 1.0, so the same submission could score differently on a re-run for no reason connected to the writing. Graders also now emit their reasoning before their score, so the number is conditioned on the argument rather than rationalized after it.
  • Eval reports now read like a person wrote them. Every sentence an eval shows you - the reasoning, the critiques, the suggestion titles and details, the what-changed line - is written against one house style: lead with the finding instead of clearing your throat, name the actor and let them act, cut the adverbs, be specific, and never close on a pull-quote. Plain hyphens only, everywhere.
  • Re-submitting the same prompt or message to an eval now grades it again instead of returning the earlier report. The dedup claim previously held for the life of a completed run, so a submission graded once could never be re-graded against a customer corpus that had since moved. Concurrent duplicate submissions still join a single run. Reports now also retain the full candidate quote pool, not only the quotes that were cited.
  • Prompt and Message Eval now grades blind, and grades your prompt separately from your message. The before/after report used to be written and scored by the same pass, so the reported lift was self-assessed. Generation and grading are now separate calls, and the grader scores both candidates in ONE pass without being told which is yours — so the number is a comparison, not an opinion about its own work. Your prompt and your message are now graded on their own rubrics with their own reasoning, cited quotes and worked examples; they never share a score. Retrieval is seeded from what you are trying to do and from each specific claim you make, rather than from the draft you asked us to fix. New: mode: "advisory" returns anchored, surgical suggestions (keep / add / strengthen / remove / reorder) against the prompt you already have instead of a rewrite — every anchor is verified to be a literal line from your document. Long prompts are sectioned rather than truncated and the report states how much was graded. Produced artifacts carry a usage field: an improved message is illustration_only — evidence the prompt is better, not a message to send. Your draft failing is now reported as the finding it is (input_verdict / improved_verdict / transition) instead of as an eval failure.
  • The improvement report now records WHY the revision loop stopped, alongside how many rounds it ran. transition.stop_reason is one of threshold_met, no_critique, deadline, model_call_cap, or max_iterations. iterations: 1 was previously produced by two opposite situations — the first round cleared the bar and no revision was needed, or the phase deadline expired before a second round could start. The reported score is the best round, so the two mean different things about whether that score is final. The field is optional, so a run stored before this change reads unchanged.
  • Evals: one shipped eval, renamed for clarity. The default eval is now Prompt and Message Eval (prompt-and-message-eval) — it grades two artifacts, a prompt and a message, and it is not tied to a channel. Its message input is now message (was outbound_message). Both retired slugs (outreach-eval, message-grader) still resolve and the retired outbound_message input key is still accepted, so existing integrations keep working. The gtm-default regression-harness eval has been retired; the generated subject mode it used is unchanged and still available to evals you author. Docs now carry a high-level Evals endpoint page plus a page per shipped eval.
  • evals.run now meters and caps like the other synchronous verb families: every dispatch records a usage row, and an operator-set max_evals_calls_month hard cap returns the standard quota_exceeded (429) envelope when exhausted. Unset caps (the default) change nothing.
  • Everything Amdahl writes for you now follows one house style. Chat answers, research summaries, content drafts, living documents and eval reports all compose the same rules: lead with the substance, name the actor, cut the adverbs, be specific, vary the rhythm, and use plain hyphens rather than em dashes. Previously only eval reports did.
  • Amdahl now runs on the current Claude model family. The general-purpose default moves to Claude Sonnet 5 and the deep-reasoning tier to Claude Opus 5; Haiku 4.5 is unchanged. This reaches everything that composes on your behalf, including the synthesis behind external_search and the deep-search briefs, which previously ran on a two-generation-old pinned model. Two consequences worth knowing: these models reason before answering by default, so answers can take slightly longer and the response budget was widened to leave room for it; and their sampling behaviour is fixed by the model rather than configurable, so any per-agent temperature setting you have stored is now advisory on those models. Agents pinned to an older model keep their existing behaviour.
Fixed
  • MCP OAuth discovery now advertises the host you actually connected on. The /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource documents previously returned a hardcoded origin, so a client reaching the API on a newer Amdahl domain was handed an issuer, authorization_endpoint, token_endpoint, and registration_endpoint pointing somewhere it could not reach — breaking the OAuth handshake. Every public URL we hand out (the OAuth issuer, the MCP endpoint, console deep links, and connector callback URLs) is now resolved from deployment configuration. Existing integrations need no change; if you had pinned the old discovery URLs by hand, re-run discovery instead.
  • Console and pricing links we hand back now point at a domain that resolves. The artifact-embed prompt returned a console.amdahl.co deep link and the seat-pricing constants named the old pricing page, both left over from the move to amdahl.ai. Internal notifications, connector OAuth callbacks, and the API's CORS allowlist were fixed at the same time: the callback list only offered the apex host, so a connector registered against the app host could fail token exchange with redirect_uri_mismatch, and the CORS allowlist could only ever name one domain at a time. Both domains are now accepted for the duration of the move, so links already shared keep working and nothing has to be re-registered.
  • GET /eval-runs now rejects an unrecognised status filter with 400 instead of silently returning every run unfiltered, and echoes the applied filters on the response. A malformed limit/offset is rejected too; an out-of-range one is still clamped to the allowed bound.
  • POST /evals/run now rejects a non-string value for a text input instead of stringifying it — an object no longer arrives at the grader as "[object Object]".
  • Eval runs no longer report a graded scorecard when the customer-evidence retrieval failed. A fan-out that returns zero quotes because the underlying reads errored is now refused as evidence_unavailable — kept distinct from empty_corpus, so "we could not load your customer evidence" is never presented as "your message is not grounded in customer evidence". Evidence reads are also bounded process-wide, so a burst of concurrent runs no longer exhausts the shared BigQuery embedding-function slot pool that caused those failures.
  • Fixed: eval run resources are now readable over MCP (eval-run:// and grader-kind:// wire aliases), eval run history lists return lean rows (full verdicts via ?include=full), live per-stage progress streams while an improvement report is being written, verdicts now state when the improved version passes but the submitted draft did not, and an identical submission completed within the last 15 minutes reuses its report instead of re-grading.
  • Chat turns, scheduled routines and agent runs no longer fail immediately with 400 ... "temperature" is deprecated for this model. Anthropic removed the sampling parameters (temperature / top_p / top_k) from its newer models, and a request carrying one is rejected in full before any work happens, so an affected run ended on its first turn having used no tools and produced no answer. Every surface that talks to a model now decides whether those parameters are deliverable from the model the request is actually sent to, rather than sending them unconditionally: the agent runtime, the shared completion path behind roughly two dozen internal callers, and the voice-analysis, follow-up-question and structured-output paths. Where a model accepts the parameters, the caller's requested value is honoured as before.
  • The API and MCP endpoints are reachable at app.amdahl.ai again, and the OAuth discovery document's documentation links now resolve. A production deploy briefly reverted the hostname because the deploy re-applies the committed App Platform spec, which had not yet been updated. Sign-in redirects now return you to the domain you signed in from. No data was affected.
  • Eval reports now render generated prose with plain ASCII hyphens instead of the em/en dashes the grading models write, on every surface (console, REST, MCP). Retrieved customer quotes, the evidence pool, and the text you submitted (including suggestion anchors into it) are never rewritten - a quoted utterance stays byte-identical, and anchor offsets into your draft remain valid.
  • An eval whose improvement report could not be produced now abstains instead of reporting a manufactured neutral score. Previously a failed generate or grade call returned 0.5 out of 1 with no dimensions and no quotes behind it, which read as a genuine middling grade. Separately, a report cut short by its output budget is now recovered rather than discarded, so a rich submission keeps the improved prompt and message that were written before the cut.
  • Eval evidence retrieval now retries a failed read instead of giving up on the whole fan-out. A submission rich enough to make several distinct claims plans one retrieval leg per claim, and those legs run in rounds — but the shared deadline was sized for a single round, so the better and more specific the writing, the more likely the entire fan-out was cut and the run refused to grade. The deadline now covers the rounds plus retries, and a leg whose read errors is retried with jittered backoff. A read that genuinely matched nothing, or a workspace with no customer themes yet, is still answered honestly rather than re-asked, and a sustained outage still refuses to grade rather than scoring against no evidence.
  • A grader that returned no scored dimensions for a candidate no longer publishes a 1 out of 5. Missing dimensions were filled at the floor, so a judge that scored nothing produced a confident bottom verdict a reader could not tell from a real one — and because the submitted side is the likelier of the two to come back sparse, it inflated the reported lift. Reports now also record how many rubric lines were genuinely assessed.
  • The MCP data schema guidance no longer suggests filtering sentiment_primary to objection (or the other negative labels) as a way to find or rule out objections and risk. Measured against production data, sentiment_primary is 95.2% neutral on buyer turns while a hand-coded sample of the same population found roughly 23% objection-or-stall content, so a filter on this column misses on the order of 8x of the negative content a person would flag, and a zero or low match count is not evidence an account is risk-free. The guidance now points at cluster_search and a direct search over the utterance text instead, and a structural test guards against the same guidance regressing.
Highlights
  • More trustworthy revenue analysis. Declare whether your CRM deal values are monthly, quarterly, or annual, and every report states the basis instead of assuming annual.
  • Compliance-first call ingestion. Filter or exclude calls before their transcripts are ever fetched, so a call you do not want in Amdahl is never pulled over the wire.
  • Faster GTM intelligence. Common questions about competitors, objections, champions, and pipeline now return faster and more reliably.
  • More capable developer platform. New search modes, enrichment, lookalikes, embeddable living-doc widgets, and improved OAuth discovery.
Breaking
  • Saving CRM Mappings with a deal_amount_tiers, segment_rules, default_segment or use_crm_segment key is now rejected. Existing saved settings still load — the retired keys are ignored — but a caller that builds its own payload must drop them.
Added
  • Deal amount unit. No CRM records what period its amount field is in — Salesforce Amount, HubSpot amount and Pipedrive value are bare numbers — so a workspace that tracks deals monthly had its deal values read as though they were annual. You can now declare what your amount means, per CRM source, under Settings → CRM Mappings (monthly, quarterly or annual), along with its currency. Deal and interaction queries carry that declaration beside the value as deal_amount_unit / deal_amount_currency, and the assistant reads it — so an answer says "$3,750 monthly" rather than a bare "$3,750". Your numbers are never touched: deal_amount still shows exactly what your CRM shows. We surface what it means instead of converting it, because there is no honest conversion — a one-time implementation fee isn't a rate, and only you know whether your amount is a subscription or a whole contract. Until you declare a unit it reads unspecified, so a figure is reported as unknown-basis rather than assumed annual. Onboarding now offers this as a quick (skippable) step, and your home-view setup checklist keeps a reminder open until every connected CRM has a unit — so it is easy to set later if you skip it. We never guess the value for you: it stays "Not declared" until you choose one.
  • `firmographic_segment` on interaction and deal queries: SMB / mid-market / enterprise from company headcount and revenue. It abstains rather than guess when a company's size straddles a band boundary.
  • New knowledge_base.get_node_widget operation (GET /knowledge-base/:id/node/:node_key/widget) returns one living-doc node's figure as a standalone single-visualization PageSpec, so a pinned chart or stat can render as an embeddable widget card — regenerated on read to stay in sync with the doc.
  • Pre-sync call filters (compliance control). A compliance-sensitive workspace can now ensure that non-qualifying call transcripts are never fetched into Amdahl in the first place — not filtered out after the fact, but never pulled over the wire. On a supported calls connector (Fathom, Fireflies, Gong, Grain), open the connection's Call filters section and choose the rules a call must match to be ingested: _external only_ (the call has at least one participant outside your own email domains) and/or _includes a rep_ (one of the people you list attended or hosted it). A call is ingested only if it matches every rule you set; a call that matches none is never fetched. Your own domains are added automatically, so you never type them in. A calls source stays paused until you save a filter — it will not sync a single call until the rule is in place — so ingestion can only ever begin with the control applied. The filter is inclusion-based and fails safe: if a call's roster can't be determined, it is excluded. (Granola support is coming in a follow-up; it is not offered as filterable yet.)
  • Personalized first wins in developer onboarding. New developers now start from a short list of first-win cards computed from the workspace's own data — the top account by conversation volume, the loudest conversation theme, and a named competitor from the company profile — instead of a generic template. Picking a card opens Search or Chat with the ask prefilled, so the guided tour begins on a real question about your own pipeline. Workspaces whose data has not landed yet get safe generic cards, so onboarding never blocks on thin data.
  • `external_only` on `context.query_substrate`. Restricts returned evidence to what the other side of the conversation said — customers, prospects, partners — and drops your own reps' messages and internal email. Set it for voice-of-customer, ICP, persona and positioning questions, where an internal message is evidence of what you say, not of what a buyer thinks. Defaults to false, so existing calls are unchanged.
  • Call filters can now EXCLUDE calls, not just include them. The pre-sync call filter gained an exclusion carve-out: exclude_participants (emails) and exclude_domains (whole domains, suffix-aware) on connections.set_call_filters and the atomic connections.connect call_filters. Any call where a blocked email or domain is an attendee/host is never ingested — its transcript is never fetched — and exclusion is evaluated before the include rules, so a blocked party always wins over an otherwise-qualifying call. Exclusion is a valid standalone choice ("ingest every call except anything involving @sensitive-client.com") and composes with the existing external-only / rep rules. It keeps the same fail-closed guarantee: an unresolvable roster still drops the call (we can't prove the blocked party is absent). Existing filters are unchanged.
  • Connecting a source now checks the credential against the provider — and shows you what it found. Until now, connecting with an API key stored the key and marked the source connected without ever calling the provider, so a wrong or expired credential stayed silent until a sync failed. Connecting Salesforce, HubSpot, Gong, Salesloft, Aircall, Fireflies, Fathom or Grain now makes one read-only call to the provider and records what it saw. The connection's summary (GET /connections/:id/summary) shows that evidence right away — "12,400 deals, latest yesterday" — instead of an empty card while the first sync runs, and the account name the credential opened, so a valid key pointed at the wrong account is obvious. The numbers are exactly what the provider reported, marked as such: a provider that will not give a total shows its date range rather than a made-up number, an approximate or capped figure is labelled approximate, and a real zero is shown as zero. Once your data lands, the summary switches back to your own synced data. Nothing is read that was not already read by the sync, no message or meeting content is stored, and a failed check never blocks connecting.
  • Fast Search is faster and more reliable on the common GTM asks. Recurring questions — competitor mentions by account, the rival landscape and win rate, objections, pain points, feature requests, champions, economic buyers — now run over the signal your pipeline already computed (so "which accounts mentioned a competitor in the last 60 days" is a sub-second lookup, not a slow text scan that timed out). Anything outside that set still writes SQL for you, but that call is now bounded and tolerant, so a stray bit of model output no longer aborts the search.
  • Every Search result now carries a `coverage` objectlatest_event_at, latest_ingest_at, total_rows, days_behind, is_stale — so you can show how current your workspace data is. When your data is materially behind "now" and a recent-window ask comes back empty, the result message says so in plain language, so an empty answer reads as a data-freshness gap rather than a mysterious failure.
  • Map your CRM stages while the first sync is still running. Connecting a CRM now fetches its reference objects — pipelines, stages, owners, field definitions — within seconds, so the stage-mapping review is ready immediately instead of waiting for the full sync to land. A new GET /console/crm-metadata returns the catalog, and mappings are seeded from it as candidates you review rather than facts we assume. Seeded entries never count as confirmed until a human has actually looked at them, and a follow-up pass picks up any stage that appears on old deals but no longer exists in the CRM's current catalog.
  • OAuth discovery now teaches agents how to onboard themselves. The authorization server metadata at /.well-known/oauth-authorization-server now includes service_documentation and an agent_auth block (per the emerging auth.md convention): where to register a client (open dynamic registration), the identity and credential types to expect (anonymous registration, bearer access/refresh tokens), where to revoke, and pointers to the step-by-step recipe at https://amdahl.ai/auth.md and the agent-skills index at https://amdahl.ai/.well-known/agent-skills/index.json. An AI agent pointed at either domain can now discover the full register-authorize-connect flow without human help. Purely additive — existing OAuth clients and tokens are unaffected.
  • New per-user pinned-figure operations — pins.list (GET /pins), pins.pin (POST /pins), pins.unpin (DELETE /pins/:document_group_id/:node_key) — let a user pin individual living-doc figures (by document_group_id + node_key) to their Home, each rendered standalone via the node-widget read. Personal to the caller (gated by the new pins:read / pins:write / pins:delete scopes); console + REST + Anthropic only.
  • Structured search modes on the API: POST /search/query routes each ask onto typed filters, plain-language search, or meaning-based (semantic) matching over your conversation data — force a lane with mode, or let Amdahl pick. Discover the filterable fields at GET /search/fields.
  • Enrichment endpoints: POST /enrich/company, POST /enrich/person, and POST /enrich/topic return a cached brief instantly when one is fresh, your own first-party evidence while a full refresh runs in the background, or the complete fused brief inline with mode=full.
  • Lookalikes: POST /lookalike finds the companies or deals most similar to a seed account, and POST /lookalike/themes finds the customer-conversation themes closest to any question — both ranked by similarity over your own corpus.
  • Revenue Review living document. A new grounded, always-refreshed report on new-business revenue: closed-won revenue and quarter-over-quarter growth per quarter, plus win rate by deal-size band. Every figure is a computed value the assistant reads back verbatim — the growth rate is calculated in the query itself rather than derived in prose, and each dollar amount carries its CRM period (annual / monthly / …) so a total is never silently re-scaled. It abstains where a band's sample is too small to give a stable win rate instead of reporting a swingy percentage.
  • CRM recency window. When you connect a CRM (Salesforce, HubSpot, or Pipedrive) you can now choose how much activity history to import — a month window (e.g. the last 12 or 24 months) or all history — so you decide up front how far back emails, meetings, calls, notes, and tasks are pulled. Your account, contact, and deal records always land in full; the window only bounds the activity/communications data. It defaults to the last 12 months, and you can change it any time on the connection — widening re-pulls the older activity, narrowing drops what falls outside the window, and reference records are never touched.
  • CRM comms filter (external / rep-involved). For HubSpot connections you can now filter which email activity is imported to only communications that involve an external party (a customer/prospect) or a specific rep — internal-only or off-topic emails are dropped before they ever land in Amdahl. Your account, contact, and deal records are never affected. It's a soft filter (when a message can't be classified it is kept), it's off until you configure it, and changing it re-pulls just the affected email activity to apply the new rule.
  • The assistant's learned data-access rules now reach the fast search-to-SQL writer and connected MCP clients, not just the living-doc author. When Amdahl learns a workspace-specific access rule (e.g. a field substitution or a grain caveat that keeps a query correct), that rule is now projected into every place SQL is written, so answers stay consistent across the console's fast search, the MCP surface, and living documents. Inert until a rule is learned; no change to existing behavior.
Changed
  • Connect a call recorder and set its filter in one step. Connecting a call recorder (Fathom, Fireflies, Gong, Grain) now lets you choose what it ingests in the SAME action as connecting it — one request instead of connect-then-configure. On the API, POST /connections (connections.connect) accepts an optional call_filters for an api-key recorder: pass an enabled filter to keep only matching calls, or { "enabled": false } for the "ingest all calls" opt-out. The filter is fully validated before anything connects, so an invalid choice never leaves a half-configured source, and the recorder starts syncing with your decision already in place. Omit call_filters and nothing changes — the recorder stays paused until you set a filter later, exactly as before. Setting a filter still requires workspace admin.
  • Call recorders: choose what you pull, right when you connect. Connecting a call recorder (Fathom, Fireflies, Gong, Grain) now asks you to decide up front what it ingests: set a pre-sync call filter to keep only calls that match (e.g. calls with an external participant and/or a named rep — everything else is never fetched), or pick Ingest all calls to bring in everything. The source stays paused until you choose, so a workspace that must filter for compliance can't accidentally start pulling calls before it's configured, and a workspace that doesn't want filtering is one click away. Already-connected sources are unchanged, and their manual Sync now works as before.
  • Customer-voice living documents (persona voice deep-dive, voice of customer, cross-persona divergence, proof & ROI) now surface genuine customer and prospect voice. Utterances from non-sales relationships that share a prospect's email domain — investors, advisors, press/media — are filtered out, while a real buyer on such an account is kept via their attached deal. Each voice panel now spreads across many speakers and accounts instead of over-representing whoever happens to have the most high-scoring quotes. data.cluster_search gains an optional facet_filter so themes can be sliced by account relationship (account_relationship) or deal context (deal_status / deal_stage / deal_presence).
  • Deal amounts now carry their period in your reports. The living GTM reports that total or average deal value — Win/Loss, Pipeline Health, ICP Signal, the GTM Health Report and the Weekly GTM Digest — now state the native period of every deal-value figure (e.g. "$1.2M open pipeline (monthly)") and never annualize it, reading the deal_amount_unit you declare per CRM source under Settings → CRM Mappings. A workspace that tracks deals monthly no longer has its report figures read as though they were annual. Where no unit is declared the report calls the basis unknown rather than assuming annual, and where you connect more than one CRM with different units it states both rather than summing across them. Your numbers are untouched — deal_amount still shows exactly what your CRM shows; the report surfaces what it means instead of converting it.
  • Ask about pipeline value in chat and the answer now states the period too. When you ask an Amdahl agent — or your own MCP client — for pipeline or per-stage value from the ready-made funnel rollup (not just the living GTM reports), it now reads the deal_amount_unit you declared per CRM source under Settings → CRM Mappings and states the native period next to the figure (e.g. "$3.3M in the POC stage (monthly)") instead of presenting a monthly figure as though it were annual. A workspace that tracks deals monthly now gets an honest basis on every ad-hoc pipeline answer, matching what Win/Loss, Pipeline Health and the rest of the living reports already do. Your numbers are untouched — the figure is exactly what your CRM shows; the answer surfaces what it means.
  • Workspace domains are active the moment you add them. Adding a company email domain under Settings → Team → Domains used to leave it "Pending verification" with no way to complete the verification — so turning on domain self-join did nothing and eligible teammates never saw your workspace in the join picker. The verification step is gone: every domain you add (and every domain already sitting at pending) is active immediately, and with Domain self-join turned on your teammates can find and join the workspace from signup or the workspace switcher right away. The guardrails are unchanged — public mailbox domains (gmail.com and the like) still can't be claimed, self-join stays off until you opt in, and the workspace owner is emailed on every join.
  • Fast Search (POST /search, the search MCP tool) now handles multi-intent asks. A question that carries more than one independent data intent — "the objections we hit from 11x, and how are deals with Acme going?" — is planned into separate sub-questions, each queried and returned as its own entry in a new groups[] field (the flat internal field is retained as the primary group for existing callers). When part of an ask is not a warehouse question (advice, "what should I say"), the response now carries an escalate_reason pointing to Chat while still returning the data groups it could answer.
  • The public API and MCP surface is now Search + Agents. External callers see two surfaces: Search (search.run) — a synchronous query over your customer conversations, optionally blended with a quick web + news pass — and Agents — the multi-turn Chat ask-door, a reusable agent library, and cron-driven Routines. The MCP server exposes exactly the search and agents coarse tools, and an external credential — a platform API key OR an MCP/SDK OAuth token — can call only the operations behind them over REST; other endpoints now return 403 not_on_public_api. Nothing changes inside the Amdahl console — every workspace surface stays available there.
  • Meaning-based (semantic) search now ranks individual customer utterances, not whole calls. A "sounds like…" / "about X" query on search (query) previously matched at the conversation level and returned one representative snippet per call; it now retrieves the specific buyer-voice utterances closest in meaning to your query, each carrying its own verbatim text and naming the parent call it came from (interaction_id on every match). Matching is scoped to the customer/prospect side of the conversation, so a voice-of-customer search surfaces what buyers said rather than your own reps. Results are drawn from a fresh rebuild of the semantic index at the finer grain — brand-new coverage fills in over the first sync after release.
  • Enrichment briefs (POST /enrich/{company,person,topic}) now stay fresh. Fast mode still returns a cached brief instantly, but it also kicks off a background refresh on every hit, and cached briefs now expire after 24 hours (previously 7–14 days). You get the same fast answer while the underlying brief is continuously kept current.
  • Revenue now reads as true ARR where your CRM records it. The Revenue Review living document and the assistant's revenue math now prefer native annual recurring revenue (from HubSpot's ARR field) and fall back to the deal amount only where ARR is not set — instead of summing the CRM deal amount, which overstates revenue for multi-year contracts (their whole-contract value is not annual). Every figure states its basis (ARR vs deal amount / total-contract value), and the assistant is steered to the ARR-preferring measure when it writes revenue queries. Where ARR is not yet populated the totals are unchanged, and they sharpen automatically as the CRM ARR passthrough fills in.
Removed
  • Deal amount tiers. The hand-authored amount bands under Settings → CRM Mappings (and the deal_amount_category / company_segment_normalized values they produced) are gone. They bucketed a deal amount whose unit was never declared, so they were wrong for any workspace tracking deals monthly. Use firmographic_segment for company size, and read deal_amount with its new deal_amount_unit for deal value.
  • `deal_size_band` is no longer returned by deal and interaction queries.
Fixed
  • The `data` tool no longer mistakes your own company for an account. The schema guidance now states that company_name is the EXTERNAL party on each interaction (the prospect / customer you're talking with), never your own business — every query is already scoped to your workspace. This stops the SQL-writing agent from adding a company_name LIKE '%<your company>%' filter to "find our calls," which silently returned zero rows even when the conversations were there.
  • `context.query_substrate` now honours your `query` on the broad intents. On research_overview and summarize_research the query text reached theme ranking but not the evidence ranking, so those two intents returned a spread across every account in the session rather than the ones matching what you asked — the wider your workspace's data, the more off-topic the result looked. Both now rank evidence by relevance to your query while still favouring a spread of accounts over many quotes from one. The other intents are unchanged, as is a call with no query, which is still a deliberate broad scan.
  • An abandoned connect no longer sits in "Connecting" forever, and no longer reports itself as healthy. When an OAuth handshake did not finish — you closed the provider's consent screen, the provider rejected the request, or the authorization link expired — the connection was left in a state only the completed handshake could clear: nothing retried it, no sync could reach it, and connections.get / list reported its health as healthy even though no credential existed and no data was flowing. Two changes: a connection with an unfinished authorization now reports health: "needs_reauth" (and, where connection health alerts are switched on, can raise one), and an authorization left unfinished for over an hour — past the point its authorization link can still be used — is now released to status: "disconnected", which is the state connections.reconnect restores from in place. No connection that holds a credential is affected, and an in-flight connect is never interrupted.
  • `search` now answers the question you asked. A negative-sentiment ask ("pain points", "complaints", "concerns") was being rewritten to "objections" before it ran, and a company or account name in the ask ("objections from Acme") was silently dropped — so the answer came back mislabeled or scoped to everyone. The planner now keeps the exact sentiment word you used (and invents none you did not), preserves every entity name verbatim, and the curated fast-search templates route on your original wording rather than a paraphrase. An entity-scoped ask now scopes to that entity, and an aggregation or "themes" ask falls through to the query writer that groups correctly instead of dumping raw rows.
  • `search` meaning-based (semantic) matching now spans your whole corpus. Results were collapsing to a single conversation with identical similarity scores because the semantic index only ever populated a thin sliver of it. The index now builds fully, so a "sounds like…" search ranks across every relevant conversation. An entity-scoped, filter-shaped ask is also routed to the exact-filter path rather than semantic matching, where a name match is what you want.
  • `search` accepts a pure distribution query. A group_by + metric aggregation with no filter ("distribution of sentiment across everything") is now accepted instead of rejected as an empty request.
  • `search fields` lists the allowed values for enum fields. Low-cardinality fields (sentiment_primary, record_type, outcome_band, and similar) now carry their sample_values, so you can discover the vocabulary from the field catalog alone.
  • `enrich` returns your first-party evidence for real customers. A cold lookup on a company, person, or topic was timing out its evidence read and reporting zero matches even when the account had thousands of conversations; the read now has room to complete. enrich on a company also accepts a name when you have no domain, resolving it to the account in your workspace instead of erroring.
  • `lookalike find` works by domain. Passing a company domain now resolves it to the seed account before the similarity lookup — the by-domain path previously reported that centroids were not built. The "not found" reason is also split into distinct domain_unresolved / entity_not_found / centroids_not_materialized / error cases so you can tell what actually happened. `lookalike themes` now honours limit, returns the real theme id and match score, and de-duplicates repeated themes.
  • Meaning-based (semantic) search now respects per-member data-scope. When a workspace restricts a member to specific accounts (a data-access rule), a "sounds like…" / "about X" query on search (query) now returns only customer utterances from the accounts that member is allowed to see — the fast semantic index applies the same company scope as structured search and the rest of the data surface. A member with no restriction, and an admin/owner, are unaffected.
  • Quarter and date-range totals are now correct at the day boundary. When a question or report filtered a date column with an inclusive "through <date>" bound (e.g. deals closed _through the last day of the quarter_), any record whose timestamp fell later that same day was silently dropped — so a quarter total could omit a deal that closed at 5pm on the final day and overstate the quarter-over-quarter change. The assistant now compiles every such bound to the correct end-of-period boundary automatically, on every data surface, so a "through June 30" total includes all of June 30. Your queries need no change; explicit end-of-day timestamps are untouched.
  • Breakdowns abstain instead of inventing a chart over sparse data. A rate or breakdown (e.g. win rate by industry) is only shown when the grouping field is populated on enough of the population, and a per-group rate is only reported once its sample is large enough to be stable — so a chart is never drawn over a field that is a fraction of a percent populated, and a "win rate" is never computed from a handful of deals.
  • Restored the pre-sync call-filter safeguard for meeting/call connectors (Fathom, Grain, etc.). An internal query referenced a column that no longer exists, so the gate that pauses an unconfigured call connector until you pick a transcript filter was erroring on every check and falling open. The gate is enforced again — a new call connector stays paused until you choose a filter or explicitly opt into ingest-all.
  • Semantic search (POST /search/query with mode: "semantic") no longer returns unlinkable duplicate rows. A small set of customer utterances in the source data carried no parent conversation id and shared a placeholder embedding; a query that happened to match them returned a cluster of near-identical results with a null interaction_id and no way to open the underlying call. Those rows are now excluded end to end, so every semantic result names the conversation it came from.
Added
  • version_provenance on knowledge_base.upload. When you append a version of a living document, you can now record which of the run's data queries each section was written from, as [{ heading_anchor, query_keys }]. Those sections become live: the console underlines them and shows how the underlying query has moved since the last version. Optional — a section you declare nothing for is simply left unbound, and a key naming a query the run did not execute is discarded.
Breaking
  • Chat and Routine runs now default to `deep` investigation depth. A run that does not set depth explicitly previously used standard; it now uses deep — which runs on a more capable model, allows more turns, and turns on external web search and the divergence view by default. Deep runs are more thorough but slower and cost more. To keep the previous behavior, pass depth: "standard" (or "quick") explicitly on the chat/routine config.
Added
  • Data Filters can now exclude specific CRM deals, not just calls and emails. An excluded deal drops from the deals data surface, win-rate, and pipeline value, and its linked interactions are removed from the corpus — reversible by removing the rule. The filters preview also shows a per-object breakdown (deals, companies, contacts).
  • Your workspace agents can now build and manage other agents and routines from inside a Chat. The Master (and any sub-agent it dispatches) can create, edit, and retire workspace agents (agents.create_agent / update_agent / delete_agent) and scheduled routines (routines.create / update / delete / run_now) directly, instead of only drafting them for you to save. A created agent runs under the same permissions as the agent that made it, and the assistant asks for confirmation before deleting an agent or a routine.
Fixed
  • "Apply" on CRM Mappings now reliably re-normalizes your data, even when the corpus has silently drifted. The pipeline only re-normalizes when the saved mapping version differs from the applied version, so a workspace whose data drifted from a stale "applied" stamp (e.g. an old backfill that marked mappings applied without fully normalizing) could show thousands of pending rows in the impact preview yet have "Apply" do nothing. Apply now reopens that gate before triggering the pipeline, so the re-normalization actually runs.
Added
  • CRM stage mappings are now auto-suggested for a workspace that has none. Opening CRM Mappings on a workspace that has never configured them now generates a first-pass set from your own pipeline data — each of your CRM's deal stages classified into a standard funnel stage — so the page starts with editable suggestions instead of blank. Suggestions are marked as auto-generated and are never applied to your normalized data until you review and save them.
  • Answers now bind claims to their evidence inline. During synthesis the agent hyperlinks a specific factual or qualitative claim to the citation / table / chart / metric block that backs it, right in the prose, using the closed amdahl:cite link grammar — a markdown link whose destination is amdahl:cite?block=<id> (block required — the id of a presented evidence block; fu optional — a follow-up question). Rich clients render the bound phrase as an evidence chip; every surface (Chat READ, terminal answer frame, live SSE) carries it. A cite that points at a block the answer never presented, or that fails the closed grammar, is unwrapped to plain text, and the flattened answer_text for MCP consumers / logs strips the scheme entirely so prose reads clean. Orthogonal to the existing amdahl:q figure-exploration links — the two never clobber each other.
  • A historic living-doc version can now show its provenance. knowledge_base://<id>/suggestions?resolved=1 (REST GET /knowledge-base/:id/suggestions?resolved=1) returns the suggestions APPROVED into that version or DENIED against it — accepted suggestions matched by the version they produced, dismissed ones by the version they were proposed against — each carrying its status, resolved_at, resolved_by_user_id, and resolution_note. The default (proposed) read is unchanged.
  • Living docs now have a per-doc "Review before publish" toggle. A living-doc workflow tagged for auto-update publishes each refresh straight to current; flip a doc to "review before publish" and each new refresh instead lands as a proposed version for a human to promote, keeping the approve/decline review meaningful on docs that otherwise auto-supersede pending suggestions. Read the current mode with GET /knowledge-base/:id/review-mode (resource knowledge_base://<id>/review-mode) → { document_group_id, review_before_publish }; set it with POST /knowledge-base/:id/review-mode body { review_before_publish: boolean }. Default is unchanged (auto-update) for every existing doc. The setter is console/REST + Copilot only (not on the MCP coarse tool).
  • A successful SQL-shaped tool call now carries a bounded preview of the rows it returned. The tool_complete agent event (live SSE, persisted step_data.events, and the Chat run READ ?include=events replay) gains an optional resultSamplecolumns, up to 25 rows keyed by column, and a truncated flag when the real result had more rows or columns than shown. Present only for tabular successes (data.query, data.ask, search.run); absent for non-tabular, empty, or failed calls. Lets a client render the actual returned data (table or chart) instead of just a row count.
  • The CRM Mappings settings tab now opens with a deal-grain summary of your pipeline — total deals, open pipeline, and won / lost value — plus a per-stage deal count on every mapping, so you can map your CRM stages against the numbers you already know.
Changed
  • Cluster-driven figures in Chat answers are now citable. A count that comes from a customer-conversation theme — "349 SMB deals were lost", "541 executive conversations" — comes from a data.cluster_search / data.cluster_detail read, not a warehouse row, so it previously had no openable evidence to link to. Answers can now present a theme as a cluster_finding block (its title, insight, narrative hook, and member count, plus representative rows) and bind the figure to it with amdahl:cite, so clicking the number opens the actual theme behind it.
  • Every backable figure links reliably, with no fixed per-answer cap. The linked-data-phrase guidance is now coverage-based — link every figure a presented block backs, not a favored two or three — and a deterministic pass at the answer boundary wraps any bare figure that exactly and unambiguously matches a presented block's headline value (a metric value, a theme's member count) in a cite to that block. A figure the answer wrote as plain prose still carries its evidence link; it can never bind to the wrong block.
  • "Continue" after a stopped answer keeps its context. Stopping an answer mid-stream and then sending "continue" no longer cold-starts as a fresh session — the stopped turn's question and partial answer stay in the conversation's context, so the agent picks up where it left off instead of asking you to restate what you were doing.
  • Chat asks a sharp clarifying question when — and only when — it matters. When a request is ambiguous in a way that would genuinely change the answer (Enterprise vs SMB, win-rate vs velocity, "recent" as 30 vs 90 days) and guessing wrong is costly, the agent now pauses with one question, preferring a quick multiple-choice pick when the options are enumerable. Otherwise it answers and states the assumption it took as a one-click steerable fork — never a wall of clarifications, never a forced gate.
  • Verbatim customer quotes in Chat answers now render as citation evidence, not quoted prose. When an answer leans on a customer's exact words, the agent presents the quote as a citation block (kind utterance — an attributed, openable evidence card) and binds the claim to it inline with amdahl:cite, instead of stacking literal double-quoted lines in the prose. Answers also now bind every specific and qualitative claim to a REAL presented block — a table / chart_spec carrying the underlying query + rows, or the cited quote — so a reader can open the actual evidence behind each statement rather than take it on faith.
  • Theme-search tool cards now preview their results. An expanded data.cluster_search step in the Chat transparency panel shows a sample of the themes it returned (label, score, representative quote), matching the row preview already shown for data.query / data.ask / search.run. A degraded or empty theme read shows no preview.
Fixed
  • Living-doc suggestions now match the version you're viewing. The suggestion rail + inline redlines on a living document are pinned to the version being rendered, so proposals authored against a superseded version no longer appear mis-anchored on the current document. knowledge_base://<id>/suggestions filters to the requested version by construction.
  • CRM Mappings settings no longer fail to load for workspaces whose saved mappings predate deal-amount tiers. A mapping record created by an earlier import could be missing its amount-tier section, which surfaced as a server error on the settings page's change-impact preview and left the page unable to load. Older records are now always read as a complete, well-formed mapping, so the page loads reliably regardless of how the mappings were first created.
  • Living-doc suggestion rationales no longer overcount related fields. A section-level suggestion's "why" now counts the DISTINCT fields that moved, so it reads "…(+9 related fields)" instead of the absurd "…(+240 related fields)" that appeared when the same handful of fields rolled up once per subsection. The suggestion's stored evidence is likewise collapsed to one row per field.
  • Living-doc suggestion rationales are readable now. A refresh suggestion's "why" is a short human phrase describing the primary change (e.g. "Days Since Last Touch (mean) rose from 65.05 to 71.39") instead of a raw dump of dotted field paths and long decimals, and immaterial sub-5% field wobbles are dropped from the evidence at the source so they never crowd out the change that actually moved.
  • The data assistant no longer wrongly claims Salesforce workspaces have no email. The record_type / interaction_type schema guidance the NL→SQL assistant reads (via data://schema) stated that email interactions exist only for HubSpot workspaces and that a Salesforce-CRM workspace has zero record_type='email' rows. That was incorrect — Salesforce logs emails as Task/Event activities that the pipeline normalizes to record_type='email', so they are in the corpus and queryable. The assistant now answers email questions on Salesforce workspaces instead of refusing; coverage is still source-dependent (email comes from HubSpot, Salesforce, Salesloft, and Pylon, while Gong is calls-only), so it still checks the live sample_values before filtering.
  • The CRM Mappings change-impact preview no longer shows a misleading "0 rows would re-normalize" right after mappings are first generated. On the very first load of a workspace's CRM Mappings settings, the impact preview could race the initial auto-generation and compute over an empty mapping set — reporting 0 even though applying the mappings would re-normalize a large number of rows. The preview now waits for the generated mappings (across app instances) so the number always reflects what an apply would actually change.
  • Auto-generated CRM stage mappings classify contracting and closing stages more accurately. A late-stage "Contract" (or legal / signature / procurement) stage is now mapped to Negotiation rather than Proof of Concept, which is reserved for actual technical trials/POCs. This improves how deals in those stages roll up into your funnel view.
Added
  • `data.query` time windows without hand-written SQL. A new optional last_n_days parameter (operation + MCP data tool) injects the correct event-time window for you — pass last_n_days: 90 instead of authoring TIMESTAMP_SUB / interval syntax. The data://schema resource now carries a sql_dialect block documenting the warehouse's SQL rules, and failed queries return targeted rewrite hints (e.g. Postgres-style INTERVAL '90 days'INTERVAL 90 DAY) instead of raw parser errors.
  • Chat answers now carry suggested follow-up questions. The answer envelope on every surface (run READ, terminal SSE frame, MCP chat_status) includes answer.follow_ups — up to 4 complete, runnable next questions the agent attached to its final answer block. Render them as one-click chips or feed one straight back into chat.start / respond; the field is always present and empty when the agent supplied none.
  • Chat now shows how it understood your question. Before answering, a fast enrichment phase interprets your plain question against your business context and standing reports, and the run READ carries an optional intent_brief (original_query, interpreted_intent, expanded_question, hints[]) so clients can render a collapsed "How we understood your question" card. Advisory only — your original question is always answered verbatim, and a skipped or failed enrichment changes nothing.
  • Suggested queries come from your Living GTM Docs. GET /console/suggested-queries serves deep, specific example questions derived from the living-doc catalog, tagged per persona (executive / developer) and surface (search / chat) with the source doc attached.
  • Charts in answers: four new shapes. chart_spec blocks accept funnel, radar, treemap, and gauge alongside bar / line / area / pie / scatter — same { x, y, series? } encoding; gauge renders the first row (label, value, optional max).
Changed
  • Master Chat commits living docs only. agents.delegate now requires the new agents:delegate scope (split from workflows:delegate, which still gates agents.start / agents.fork_blueprint). Master no longer resolves the specialist-start tools; lasting saves go through outputs.write_doc. Discover valid living-doc slugs via GET /outputs / output://list.
  • Blended fast search now enriches the web query with your workspace context. In POST /search with mode: "blended", the web + news citation pass no longer runs your raw ask verbatim: a cached, tenant-aware rewrite folds in your company and industry context first, so ambiguous asks resolve to _your_ market instead of the public internet's dominant sense of the words. When an ask reads as purely a question about your own workspace data, the external pass is skipped with the new external_omitted: "not_relevant" instead of returning off-topic links (this never blocks a healthy fan-out — uncertainty runs the search). The result's external.external_query_used shows the query the web pass actually ran. Also in this release: agents.start with async: false no longer reports a slow specialist as an error — if the run is still working after the ~60s bounded wait, the call returns success with status: "running" and still_running: true (the async contract), and a run that genuinely failed mid-wait surfaces the honest run_failed code; and data.query results now return BigQuery date/timestamp cells as plain ISO strings instead of {"value": ...} wrapper objects, on every protocol.
  • Product feedback Slack pings always attempt delivery (no production-only env gate); still require INTERNAL_SLACK_WEBHOOK_URL.
  • Cost accounting is server-internal; runs no longer loop on a finished answer. The Chat run read (GET /chats/:id/runs/:run_id, chat://<id>/runs/<run_id>, the agents tool's chat_status) now reports usage as tokens + turns_used only — estimated_cost_cents is gone, as is cost_cap_cents on chat summaries and the conversation:// / agent_run:// reads. The max_cost_cents config knob is removed from chat.start, chat preferences, and routine configs (sending it now rejects as an unknown field); every new chat gets the platform-managed limit automatically. The cost_cap_exceeded pause is renamed usage_limit_exceeded with a unitless payload: reply { action: "continue" | "stop" } — on continue the platform extends the limit itself. Separately, imperative first messages no longer force tool use past the first model request, fixing runs that re-presented the same answer repeatedly before settling.
Fixed
  • Champion & EB Voice Digest no longer reports a phantom "coverage decline." The first time a workspace runs the digest after the champion-scoring fix (which excludes the neutral-default 0.75 stakeholders), champion/EB counts step down versus the older, un-gated version. The digest now labels this as a one-time scoring correction and treats the new counts as the baseline — instead of inventing a real-world decline (seasonality, pipeline gaps, "cleaned test data") or recommending an investigation.
  • Pipeline Health Report now refreshes for workspaces with many pipeline stages. The report's stage-by-stage funnel (and the open-pipeline stage breakdown) were capped at 30 and 25 rows — a complete per-stage aggregate that must not be truncated, so a workspace whose Salesforce pipeline has more stages than the cap (including legacy or #REF! stage labels) failed the whole run and the living document never updated. The caps are raised to the query engine's row limit, so every stage is included.
  • Living GTM Docs no longer stall on large workspaces. The ICP Signal Report, Win/Loss Report, Deal Qualification Report, and Pipeline Health Report each ran a few "cover the whole population" queries (e.g. per-industry, per-segment, per-competitor breakdowns) under a hand-picked row cap. On a workspace whose true breakdown exceeded that cap, the scheduled refresh aborted and the document froze at its last version. Those completeness queries now read the full set, so the docs keep refreshing no matter how broad the workspace's customer base, pipeline, or competitor field grows.
Added
  • Agents are now a first-class primitive: list the platform agent roster and author your own workspace agents (a named prompt with a stable slug) via the new GET/POST /agents API family. Amdahl-shipped agents (starting with researcher) appear alongside your own; platform agents are read-only.
  • Agents can now take outbound actions — with your permission: the new POST /actions/invoke API (and the matching agent tool) fires one of two outbound actions: email_member (email current workspace members — external addresses are always rejected, sends are capped and idempotent) or notion_sync (push a knowledge-base document into your connected Notion now). Actions are opt-in per run via an actions_allowed list that defaults to empty, so an agent that was not explicitly granted an action cannot send anything — it proposes instead. GET /actions (the action://list resource) shows each action's live availability and remaining send budget, and a failed action is never retried automatically.
  • Agents can now keep long-term memory honestly: the new POST /memory/write API (and the matching agent tool) commits durable facts to the workspace context store. Every agent-written entry is automatically tagged auto_detected, and entries a human wrote (manual) are protected — an agent that disagrees with one must add a new flagging entry rather than edit yours.
  • Agents can now ask a human: the new ask_a_human agent tool pauses a run to put one structured question to a person — multiple_choice (2-6 options, with a guaranteed "Other" write-in the platform injects) or free_form. Only the asking run waits: a sub-agent's question is escalated to its caller's event stream as a child_needs_human frame while sibling work keeps running, and the answer resumes the waiting run directly through the existing resume API as a { answer, option_id? } tool result. Runs started with on_question: "auto" skip the ask and proceed on stated assumptions; headless / scheduled runs (on_question: "none") get an immediate no_human_in_loop error instead of a hang.
  • See exactly which tools a workspace agent can block: the operation catalog read takes a new blockable_for=master filter (operation://list?blockable_for=master, REST GET /operations?blockable_for=master) that returns just the tools an agent actually runs with — its granted kit, minus reads and the protected loop tools a blocklist can never remove — instead of the full operation catalog, so a tool-blocklist picker only offers tools it can meaningfully block.
  • Chat reads now tell you what triggered a run: every chat + run read (GET /chats, GET /chats/:id, GET /chats/:id/runs/:run_id, and the agents MCP tool's chat reads) now carries a trigger (chat · agent · routine), the routine ({ id, name }) behind a scheduled fire, and the agent_ref a run is pinned to — so a routine fire and an agent run are distinguishable from a plain chat without inferring it from the session title.
  • Agents can now delegate: the new POST /agents/delegate API (and the matching agent tool) dispatches a sub-agent — a roster agent like researcher, one of your own workspace agents, or a one-off inline prompt — to investigate a single goal and report back with an answer, evidence, and suggestions. Pass task, plus optional desired_output and context, to shape what comes back. Delegation is async (the call returns the child run id immediately; progress streams into the caller's session), sub-agents run with investigation-only permissions (they never write documents, memory, or send anything), and delegation depth is capped at one level.
  • Multi-agent Routines: config.agents picks who a routine employs — "all" (plain Master, unrestricted delegation), one ref (the fired turn runs AS that agent, the pin), or several refs (the Master may delegate ONLY to that roster; other agents and inline prompts are refused by agents.delegate with agent_not_allowed). Every roster member re-resolves at fire time. The legacy single agent pin still works; passing both rejects. The output_slug / full_regen doc-target knobs left the writable config — the agent picks its own document slug via outputs.write_doc.
  • Workspace agent controls: slug is now optional on POST /agents (derived from the name and uniquified when omitted), and every workspace agent accepts a tool_blocklist — operation ids subtracted from the agent's kit at run time (all kit tools are on by default; runner-inline tools like finish cannot be blocked).
  • Fast search — data back in one call. POST /search is the synchronous fast lane beside Chat: it turns your ask into SQL over your interaction warehouse and runs it, blocking for a single answer (a ~15s hard ceiling) with no agent run. Pass mode: "blended" to also fan out a quick web + news citation pass (internal is the default, warehouse only), limit / external_limit to size the result, and synthesize: true for a one-paragraph headline. It returns the rows, the SQL that ran, and any citations. Reach for it when you want data now (a dashboard cell, a slash command); reach for Chat when the ask needs a multi-step investigation. Every failure mode is a typed field on the result — an unsupported ask, zero rows, a timed-out source — never a raw error, and a partial answer always beats a 500. Also on MCP as the search tool (one action, run). Needs only data:read; the blended leg uses external_search:execute and degrades to internal-only without it. See the new Search guide.
  • The search MCP tool. Connected MCP agents (Claude, Cursor, ChatGPT) can now ask Amdahl anything end-to-end: start opens or continues a named Session and runs one Master agent turn server-side, returning handles immediately; status polls the run (with an optional wait_ms long-poll up to 30s); respond answers a paused human question; cancel stops a run. A deep run is never crammed into one tool call — start, check in, respond. Session reads are also MCP resources (session://list, session://<id>, session://<id>/runs/<run_id>).
  • The agents MCP tool: the workspace agent library (list / get / create / update / delete named, reusable prompts — Amdahl library agents are locked) plus Routines in the same tool (create_routine with a prompt + cron for a standing scheduled ask, update_routine, delete_routine, and run_routine_now, which fires immediately and returns the same handles a Search start does). Roster and routine reads are also MCP resources (agent://, routine://).
  • Existing MCP API keys were backfilled with the new scopes automatically — no rotation needed. Read scopes (conversations:read, agents:read, routines:read) landed on every bundle; write scopes (conversations:write, workflows:write, agents:write, routines:write) on customer-agent keys and up.
  • Routines are live: a Routine is a cron that fires a Search — each occurrence opens a fresh Session named "{name} — {date}" and runs one Master agent turn, headless (ask_a_human fails fast with no_human_in_loop; a routine never parks on a person). Full CRUD plus an off-cadence trigger: POST /routines, GET /routines[/:id], PATCH /routines/:id, DELETE /routines/:id, and POST /routines/:id/run-now (returns the same watch handles a Search START does). See the new Routines guide.
  • Verified document commits: the Master agent now refreshes living documents through outputs.write_doc — never a raw knowledge-base upload. Run outputs.run_bound_queries first: it executes the vetted query pack behind an analytical document and records the cells; every digit the commit states is verified against exactly those cells. Auto-promotion to the current version is refused without a verified pack (the version lands proposed for human review), and a failed theme read during the run blocks the commit entirely so a degraded read can never overwrite the last good version.
  • Answers are content blocks: a Chat answer carries answer_text plus an ordered content_blocks[] list (text, callout, citation, table, chart_spec, metric). Data-backed blocks include both the declared query and the snapshot data on every surface, so clients can render the snapshot instantly, re-run the query live, or diff the two. Agents emit blocks mid-run via the new answer.present tool; blocks stream as content_block SSE events and reconcile into the terminal answer in order.
  • Living-doc suggestion threads + a runnable data-query tree. Two review surfaces for living documents. (1) Suggestion discussion threads — every data-driven suggestion now carries its own comment thread: read it at GET /knowledge-base/:id/suggestions/:suggestion_id/comments (also the knowledge_base://<id>/suggestions/<suggestion_id>/comments resource), reply with POST …/comments (body, optional thread_root_id), and resolve/reopen with POST …/comments/:comment_id/resolve. Threads are keyed on the suggestion (not a version block), grouped root + replies, newest-first. (2) Per-node data-query tree — open any node of a living doc and see its numbers live: POST /knowledge-base/:id/nodes/:node_key/run executes that node's bound queries through the same interactions engine (tenant- and access-scoped to you) and returns fresh rows per query; POST …/nodes/:node_key/ask answers a question grounded only in that node's bound queries and their latest captured signatures. Reads need knowledge_base:read; the thread writes need versioning:write (editor). Like the existing suggestion accept/dismiss/ask + review-thread writes, the thread writes and the node compute are REST + in-app copilot only (off the MCP coarse tool); the thread read rides the knowledge_base:// resource scheme, and an MCP client runs a node's query by reading its SQL from knowledge_base://<id>/tree and calling the data tool directly.
Changed
  • The agent lane is now Chat (renamed from Search): the multi-turn agentic surface that backs the console's sidebar chat is called Chat everywhere, and the name Search now belongs to the synchronous fast-search endpoint (POST /search, shipped in this release — see its own changelog entry). What moved: POST /searchPOST /chat; GET /search/sessions[...]GET /chats[...] (rename via PATCH /chats/:id); the start body's session_id / session_namechat_id / name, and responses return chat_id (list responses return chats, detail responses chat); op ids search.*chat.* (chat.start, chat.list, chat.get, chat.get_run, chat.rename); the session:// MCP resource scheme → chat://. Over MCP the standalone search tool is retired — its four lifecycle actions live on the agents tool as start_chat / chat_status / respond / cancel_chat with the same check-in contract (START always returns handles; poll chat_status or read chat://<id>/runs/<run_id>; wait_ms long-polls up to 30s). Routines fire Chats; run-now responses return chat_id and routine reads carry last_chat_id. See the Chat guide (formerly the Search guide).
  • Chat `depth` is now a real investigation tier. quick, standard, and deep no longer differ in name only — each sets the model, the turn budget, and the toolset for you. quick runs a lean, fast toolset for a focused lookup; standard is the everyday full toolset; deep uses the most capable model with a larger turn budget, forces market search + the divergence map on, and instructs the run to decompose the ask and self-verify every figure before answering. You never pass a model — the tier picks it.
  • Two Chat config knobs were removed. evidence is gone — citations and the query behind every data-backed answer block are now attached unconditionally, so there was nothing to toggle. as_of is gone from the Chat / routine run config — an interactive Chat always reads your current data; historical "as we understood it on date X" reads belong to workflow backtests (the data.query time-travel primitive is unchanged). Sending either field now returns a clear invalid_argument error rather than being silently ignored.
  • Tool activity now reads in plain language. Each tool a run calls streams a short, human-readable label ("Searching your customer conversations") and a plain-language result summary ("30 results") on its lifecycle events, so the console's tool cards are legible instead of raw operation ids and JSON.
  • data.query now refuses a silently-truncated result: when a query's population exceeds the effective row limit, the call returns a structured result_truncated error (with guidance to aggregate in SQL or bridge on an id list) instead of an arbitrary slice. Pass allow_truncation: true for a deliberate bounded top-N (ORDER BY ... LIMIT n).
Removed
  • The blueprints MCP tool. Connected MCP agents no longer author, run, schedule, or fork workflow (blueprint) recipes over MCP, and the workflow resource reads (agent_blueprint://, blueprint_run://, blueprint_schedule://, blueprint_backtest://, blueprint_output://, step_kind://, trigger_kind://, prompt://) are gone with it. Workflows themselves are unaffected: the console Workflows surface and the full REST lifecycle (authoring, validation, one-shot runs, schedules, backtests, output promotion) keep working unchanged, and scheduled runs keep firing. From MCP, use the new agents tool's Routines for recurring work and the new search tool for one-shot deep work. MCP API keys need no rotation; the blueprints:execute scope on existing keys is simply inert on the MCP surface.
  • The pages MCP tool. Connected MCP agents (Claude, Cursor, ChatGPT) no longer author, list, or delete workspace Pages, and the page:// + page_template:// MCP resources are gone with it. Pages themselves are unaffected: the console Pages surface and the full REST lifecycle (/api/platform/v1/pages — create, validate, render, update, archive, delete, templates, embed links) keep working unchanged. MCP API keys need no rotation; any pages:* scopes on an existing key are simply inert on the MCP surface. See the updated "Pages over MCP" guide for the migration path.
Fixed
  • Account Tier Intelligence living document: corrected two accuracy issues — the triage-tier account count now matches the ranked account list, and accounts with no identified champion no longer display a default champion score.
  • API-key connections no longer land Disconnected the moment you connect: connecting any API-key connector (Fireflies, Fathom, Granola, Grain, Aircall, Salesloft, Pipedrive, Pylon) created the connection row before its key was stored, and a legacy database trigger that derived status from credential existence marked the row disconnected at birth — so the console showed Disconnected instantly and the sync scheduler (which only picks up connected sources) never ingested anything. The trigger is retired, and POST /connections (api_key) plus POST /connections/:id/reconnect now order the flow as pending → store the key → connected, so a connection only ever reads connected once its credential actually exists. Previously affected connections can be restored in place with a normal Reconnect.
  • Living GTM documents: the Persona Voice Deep-Dive and Champion & EB Voice Digest no longer count or quote accounts that carry only the pipeline's neutral default champion score as champions (matching the fix already shipped for Account Tier Intelligence), so champion totals are no longer over-stated on data-rich workspaces. Separately, the Voice-of-Customer Cluster Report no longer fails on workspaces with more than 200 conversation-theme clusters — every theme's win/loss outcome is now computed.
  • Scheduled/headless living-document runs are reliable again: the runner now always reaches the production data endpoint, fixing intermittent runs that halted with a connection error and left some living documents (persona voice, competitive divergence, weekly digest, content calendar) stale.
Added
  • Your pipeline funnel is now backtestable. Pass as_of to a data.query over the deal_funnel surface to see the per-pipeline/stage funnel as it stood on any past date — deals-ever-entered, progression rate, and median dwell reconstruct from your stage history — the same time-travel that already backed deals, deal_qualification, clusters, and positioning_claims. Stages with no history before that date report null rather than a fabricated zero, so a trend never invents numbers.
  • New API + MCP reads for a living document's data spine — and endpoints to review its suggestions. Every living document now exposes its underlying data over the API and MCP: its signal trends per bound query (GET /knowledge-base/:id/signals, knowledge_base://<id>/signals), its node → query tree (.../tree), and its pending data-driven suggestions (.../suggestions). Three companion endpoints let a reviewer act on a suggestion — accept it (apply the edit and produce the next version), dismiss it, or ask a grounded follow-up about why it fired — via REST and the in-app copilot. Accepting never overwrites a document whose current version you authored by hand. (Suggestions populate as the automatic refresh engine rolls out; the read + review surfaces are available now.)
  • Living documents now propose data-driven edits when their numbers move. When a recurring living document refreshes and a bound metric shifts materially since the last run, Amdahl now surfaces a single, evidence-backed suggestion on the affected section — each anchored to the exact number that changed, with the before/after trend behind it. Suggestions are proposed for your review (never applied automatically), so you decide what lands.
Changed
  • The Account Tier Intelligence, ICP Signal Report, and Deal Qualification Report living documents now end with a concrete Recommended Actions section — named accounts/verticals paired with a specific next step (or a short prioritized play list) — instead of stopping at diagnosis. Actions stay grounded in the underlying data: they cite each item's own signals (momentum, binding constraint, tier, undercovered dimension) and never introduce invented numbers, deadlines, or targets.
Fixed
  • Living-doc suggestions no longer surface a data-availability transition as a GTM change. When a document's only prior generation is a structural backfill seed (e.g. a deal_qualification as-of snapshot whose coverage lens is empty), the refresh engine now stays silent instead of narrating spurious "0→N" / "added N records" / "expanded coverage from 1 to 3" movements. A genuine value move against a real prior still emits as before.
Added
  • Canonical sales-pipeline metrics. New pipeline://metrics, pipeline://funnel, and pipeline://qualification-health reads (MCP resources + REST GET /pipeline/*) give one consistent, per-pipeline definition of win rate, sales cycle, funnel, and qualification — with the qualification / "stage 0" band ALWAYS shown but NEVER counted in a rate or cycle. data.query can also read the new deal_winloss (per-pipeline win rate + sales cycle) and deal_funnel (per-stage) tables directly.
  • Canonical funnel + per-pipeline win rate in the living docs. The Pipeline Health Report and GTM Health Report now read the shared deal_funnel / deal_winloss views, so the funnel (with the qualification / "stage 0" band shown but never counted in pipeline value), the qualified-pipeline value excluding stage 0, and the per-pipeline win rate + sales cycle (with qualification/stage-0 dwell excluded and reported separately) are all defined one consistent way instead of re-derived per doc.
Changed
  • Connect more than one instance of the same integration to a workspace. The API-key connectors (Fathom, Granola, Fireflies, Pipedrive, Pylon, Salesloft, Grain, Aircall) and the workspace OAuth connectors (HubSpot, Salesforce, Gong, Slack, Notion) now support several simultaneous connections per workspace instead of being capped at one. Gmail and Outlook (per-member) also work reliably when several members each connect their own account.
Fixed
  • Fixed a "The server ran into a problem" error when connecting an API-key integration where one already existed. The connect now returns a clear, actionable message instead of a generic 500 and never leaves a half-connected source behind.
Added
  • Workflow runs now record a per-step audit trail. Reading a run (blueprint_run://<id>, GET /blueprint-runs/:id, or context.review_kit) returns a step_states entry for every step the workflow executed — its kind, the tool it called, whether it succeeded, how long it took, and a short summary of what came back. When a step degrades rather than fails outright — a theme search that could not reach the warehouse, a query that returned no rows — the reason is now on the run record, instead of having to be inferred from the document the run produced. Step summaries are counts and status codes only; they never include the underlying rows or document text. Populated for workflows that run on the deterministic step executor.
  • New Actions connector category for outbound integrations — connectors that push your Amdahl data OUT to another tool rather than pulling data in. Notion Knowledge Sync is the first, and the connector catalog + the connections reads now group it under actions (filter with category=actions).
Changed
  • Living documents now read like reports, not compliance forms. Every recurring living document — GTM health, pipeline health, voice-of-customer, win/loss, ICP, account tier, and the rest — now follows a shared writing standard: it opens with the finding rather than the methodology, states a data caveat once where it applies instead of on every line, reserves bold for the few things you must not miss, and never leaks internal field names or CRM stage codes into the prose. Documents you generate from your own uploads get the same treatment. Every number stays verified against your data exactly as before.
  • Any signed-in user can now create a workspace. Workspace creation no longer rejects personal / free-mailbox email addresses.
  • You can now set your company domain while setting up a workspace. When the domain matches your own work email, it is verified automatically.
  • New discoverability control: choose whether teammates with a verified email on your company domain can find and join your workspace. It is off by default.
  • Pick your role during setup — Executive or Developer — and the console tailors where you land and what it shows first. You can change it any time in settings.
Fixed
  • A failed theme search now reports an error instead of looking like "no themes found." When the theme index is temporarily unavailable, data.cluster_search previously returned an empty result that was indistinguishable from a workspace that genuinely has no matching themes. It now surfaces as a tool error, so your agent can retry or tell you something went wrong. A workspace with no themes yet, or a search that simply matches nothing, still returns a normal empty result.
  • Scheduled living documents no longer publish a report when their theme search fails. If a workflow cannot read your conversation themes, the run now stops and the previously published version of the document stays in place, instead of quietly replacing it with a version missing its qualitative analysis.
  • Scheduled living documents no longer crowd each other out — or hold up a run you started yourself. Recurring documents used to all fire at the same moment and compete for the same processing capacity, which could cause a report to come back rate-limited and, occasionally, to arrive with nothing in it. Scheduled documents now drain through their own dedicated lane at a steady pace, so they generate reliably no matter how many are on your calendar. A document you run on demand starts right away instead of waiting behind the day's scheduled batch.
Added
  • data_access_rule:// read resource (data_access_rule://list, data_access_rule://<id>; REST GET /data-access-rules). Look up the verified data-access rules the platform maintains for the warehouse — field substitutions, surface-grain caveats, and aggregation patterns — so an agent queries your data correctly. Filter with ?surface=, ?kind=, or ?signature=.
  • New guided walkthrough prompt (system/guide_me) for MCP clients. Ask "what can this do?" or "guide me" and Amdahl runs a friendly, jargon-free, numbered-menu interview — it offers plain-English goals (meeting prep, customer voice, deal lookup, proof, research, dashboards, recurring reports), you pick a number, and it does the work and answers in plain language. Built for non-technical users who would rather pick from a menu than know which tool to call.
  • Default GTM living documents now schedule themselves for your workspace. Once a workspace has synced go-to-market data, Amdahl automatically schedules a starter bundle of recurring living documents — GTM health, pipeline health, voice-of-customer, win/loss, and a weekly priority briefing, plus deal-qualification, account-tier, and ICP reports once a CRM is connected — so useful reports start arriving with no setup. Workspaces that have already configured their own workflows are left untouched.
Changed
  • Win / Loss and GTM Health Report accuracy on high-volume workspaces. The won-vs-lost MEDDPICC differentiators, the score-calibration curve, and the per-competitor win rates in these living documents are now computed over your _entire_ closed-deal history in a single pass, instead of a recent sample. Workspaces that close thousands of deals per window — where these figures were previously computed on a truncated slice — now get complete, unbiased numbers.
  • New queryable fields on the `data` tool. deal_qualification now exposes realized_is_won / realized_is_closed (a company's realized deal outcome), and competitor_mentions now exposes competitor_class (rival vs. own-product / benchmark / model-vendor) plus the account's realized deal outcome. You can now compute score calibration and per-rival win rates in a single query, without bridging across tables, and the competitor win-rate read is backtestable with as_of.
  • data.query now accepts Common Table Expressions (WITH … AS (…)) and subqueries (in FROM and WHERE). Previously these were rejected; your workspace's tenant scoping is applied to every part of the query, so nested reads stay scoped automatically.
  • MCP-connected assistants now translate Amdahl's internal terms into plain business language when talking to you — "conversations" instead of "interactions", "topics" instead of "clusters", "a workflow" instead of "a blueprint" — and no longer surface SQL, tool names, or field names unless you ask how something works. The server instructions carry a built-in glossary so this holds across every client.
  • data.query now also accepts set-operations (UNION / UNION ALL / INTERSECT / EXCEPT). Previously these were rejected; each branch of the query is scoped to your workspace independently and the row limit applies to the combined result, so you can union or intersect results in a single query.
  • data.explore with sample values is now much faster on a cold call: the per-column distinct-value lookups collapse into a single scan of your data instead of one query per column (the first explore of a session no longer stalls for tens of seconds).
  • data.cluster_detail now accepts a durable lineage_id (surfaced on every data.cluster_search result) as an alternative to cluster_id. A theme's cluster_id changes each time topics are recomputed, so a saved handle could go stale; the lineage_id is stable across recomputes and is resolved server-side to the theme's current id, so a drill-in handle you saved earlier keeps working.
  • The GTM Health Report picks its own title again. A fixed title was briefly pinned onto the report. It was redundant — the report is already barred from describing itself, in its title, any heading, or the body, as covering a lookback period, a rolling window, a quarter, or "recent" deals — so the pin is gone and the report titles itself. The scope guarantee is unchanged.
Fixed
  • The grounding discipline behind every living GTM doc now encodes the deal-outcome surface rules directly: deal counts / revenue / win rate come from your CRM deal records, conversation-corpus outcome rates are labeled as such and never conflated with them, won+lost can exceed closed (an account can be both), numbers are stated exactly (no rounding), currency is formatted, and quotes stay in blockquotes. Applies across all ~18 living-doc generators, not just VoC and ICP.
  • The GTM Diagnostic's "where deals are lost" stage-leak analysis now works. The query behind it was silently rejected before it ever ran, so on every workspace the report saw an empty result and fell back to reporting cycle-time only (reading the blank as "no stage history"). It now computes the stage each lost deal died in, so the diagnostic can point at where in the funnel deals actually leak.
  • agents.run_blueprint now resolves a tenant blueprint by its identity.slug, not only by its UUID. Running a blueprint you authored by its slug used to return not_found; it now resolves the same way every other blueprint surface does.
  • Win / Loss Report no longer claims a 90-day window it never applied. The win-loss-report workflow declared a lookback_days input but none of its queries filtered on it, so a run headlined _all-time_ closed-deal totals under a "90-Day Close Period" title — the figures were real, the scope was not. The report now states its true basis, all closed deals to date, opens with a line giving the closed won/lost counts and noting that the MEDDPICC differentiators and per-rival win rates are computed over the closed-and-scored subset, and is barred from describing itself as covering any rolling window. The lookback_days input is removed: only the deals cut has a close date at all — the won/lost outcome reaches the qualification and competitor surfaces as a flag with no date — so windowing part of the report would have put a 90-day headline next to all-time win rates. Passing the old input is ignored rather than rejected, so existing schedules keep running. The document still refreshes monthly; its version history is the time series.
  • Champion & EB Voice Digest now actually covers the window it reports. The workflow declared a lookback_days input but none of its queries filtered on it, so the digest quoted stakeholders from your entire conversation history under a heading that implied a recent period. It now genuinely reads the last 60 days of champion and economic-buyer utterances, opens with a coverage line stating how many utterances and accounts fall inside the window out of your all-time totals, and says so plainly when nobody spoke in the window instead of padding the digest. The objection themes come from the theme index, which has no time filter, so they remain all-time and are now labeled as such. The lookback_days input is removed — the window is fixed at 60 days; passing the old input is ignored rather than rejected, so existing schedules keep running. Fork the workflow to change the window.
  • GTM Health Report no longer implies a lookback period it never applied. The workflow declared a lookback_days input (default 180) that no query filtered or weighted by. The report now states its true basis — all of your conversation and CRM data to date — and is barred from describing itself, or any finding, as covering a rolling window, a quarter, or "recent" deals. Its cuts genuinely cannot be windowed: the stage-transition analysis would silently mis-identify where deals die, the account funnel view would lose every open deal, and the score-calibration and competitor win-rate cuts carry a won/lost flag with no close date. Windowing only the rest would have put a windowed headline beside all-time win rates. The lookback_days input is removed; passing it is ignored rather than rejected. The report still refreshes on a cadence, and its version history is the time series.
  • The Champion & EB Voice Digest now carries its window in the title. The digest reads the last 60 days, but because it edits the prior version in place rather than rewriting it, a refresh could leave the old heading standing and mention the window only in a subtitle beneath it. The 60-day window is now written into the document's own title on every refresh.
  • The GTM Health Report's Home-card score reads reliably regardless of how the report is laid out. The score block that powers the card is now located wherever it appears in the document rather than only at the very top, so a report that opens with its heading can never blank the card.
Security
  • MCP OAuth: the scopes an OAuth-connected app receives are now clamped to the standard customer-agent capability set at authorization time. A requested scope beyond that ceiling (for example a wildcard *:*) is dropped rather than granted, so an OAuth token can never carry more access than the OAuth flow is meant to issue. Apps that request the advertised scopes are unaffected.
Added
  • Pages catalog: LineChart accepts yMin + yMax to pin the y-axis range, so a tight series can fit its scale to the data (no forced zero baseline) and show its variance.
  • Pages catalog: new Tabs component (labelled panels the viewer switches between — each tab carries an inline catalog subtree) and Carousel component (rotate sibling charts one slide at a time), plus QuoteList.expandable + QuoteList.detailFields for a per-quote read-in-place detail dialog with richer attribution.
  • Workspace README — a one-read orientation map of your workspace. Read workspace_readme://current over MCP (or GET /workspace/readme over REST) to get the workspace's pages, knowledge documents, agent workflows, and callable operation namespaces as name-plus-summary rows, together with a rendered markdown digest. Assembled live on every read, so it is never stale; sections your key lacks scope for are omitted with a reason instead of failing the call.
  • Salesloft connector. Connect a Salesloft workspace with an API key to sync emails, calls, conversations, transcripts, and meetings into your workspace.
  • Every Pages chart now accepts a height preset (sm | md | lg) and an ordered colors token list (chart-1..chart-5, positive, negative, neutral) so agents can size charts and paint series/slices deliberately — tokens resolve to theme variables, so white or raw hex are unrepresentable. RadarChart additionally accepts max to pin the radial scale (e.g. max: 5 for a /5 rubric fills the polygon), and FunnelChart no longer overruns its card border.
Changed
  • Living documents generated from an uploaded doc (knowledge_base.make_living) now author a deterministic, grounded refresh workflow: each regenerated version computes its figures directly from your data and verifies every number, so a living doc no longer states a figure that isn't backed by the underlying data.
  • Backtest-generated knowledge-base versions are now stamped with the as-of date they reflect. A cumulative backtest sweep of a living document produces a properly dated version history, and each slice builds on the prior one (knowledge_base.upload defaults version_as_of to the run's backtest cut; an explicit value still wins, and live runs are unchanged).
  • Competitive Divergence Map workflow (v2) now tracks divergence over time. Every run captures a structured divergence snapshot — counts by kind (internal-only / market-only / magnitude / polarity), themes marked new / persisting / resolved vs the prior run, and render health — in three places: the workflow run's outputs (blueprint_run://<id>), a machine-readable header on the living doc (so the knowledge-base version history is a parseable series), and an accumulating "Divergence over time" chart on an auto-upserted dashboard page. The run also factors in more data: per-rival market research (bounded by the new max_rivals input), your positioning docs from the knowledge base, and the Industry sweep corpus — and the category market read is force-refreshed so a scheduled run never records a cached brief as a fresh snapshot.
  • The Account Tier Intelligence and ICP Signal Report living documents are now generated by the deterministic step-executor — every figure (close-likelihood scores, engagement momentum, champion strength, win rates by vertical) is computed from your data in SQL/transform cells and number-verified, so the document can no longer state a value that isn't backed by the underlying data. Account tiering (Expand / Nurture / Watch / Triage) is computed deterministically from the real signals rather than narrated by the model.
  • Fixed a blueprint-runner bug where a workflow's transform step could not read a workflow input via the canonical $inputs.X reference, which could cause a defaulted threshold to be treated as unset.
  • The Deal Qualification Report living document is now generated by the deterministic step-executor: every figure (coverage-band distribution, per-dimension MEDDPICC/SPICED coverage percentages, binding constraints) is a verified data cell, the thinnest-coverage deals are named with real company names (bridged from the interactions surface), and dimension coverage is reported as a clear percentage of scored companies rather than an ambiguous count.
  • The Win / Loss Report living document is now generated by the deterministic step-executor. The two cross-surface analyses it previously asked the model to compute by hand — the won-vs-lost MEDDPICC coverage spread and the per-competitor win rate — are now pre-computed as verified transform cells, so the report states real, grounded win/loss differentiators and win rates (each gated to ≥8 accounts for reliability) instead of derived figures.
  • The Pipeline Health Report living document is now generated by the deterministic step-executor: the open-pipeline total is a verified totals query (rather than the model summing stage rows in its head), the at-risk / long-shot deals are named with real company names, and every stage figure is a grounded cell the number gate verifies.
  • The Win / Loss Report now handles a thin closed-won sample honestly: when there are too few scored closed deals to compute reliable MEDDPICC win/loss differentiators, the report says so and focuses on the qualitative loss themes instead of reporting shaky percentages, and it no longer derives win-rate or share percentages from the raw deal counts.
  • Living-doc workflows now share a canonical data-surfaces map (where each GTM data surface lives, its grain, purpose, scale, and join key) plus the query patterns that keep a computed figure correct under the query row cap — so a generated or hand-authored workflow aggregates in SQL over the full population instead of silently truncating a per-row query.
  • Added a truncation guard to the deterministic workflow executor: a data.query whose result exceeds its LIMIT (detected by a peek) is flagged, so a workflow can no longer state a real-but-wrong number computed over a silently-truncated sample (deliberate top-N queries opt in via allow_truncation).
  • The Account Tier, Win / Loss, Deal Qualification, and Pipeline Health living documents now compute their cross-surface figures over the FULL population instead of a silently-truncated sample: metrics that need every company (win/loss MEDDPICC differentiators, account tiering, named thin/at-risk deals) use an id-list bridge that aggregates in SQL over the exact set, so — for example — a tenant's win/loss report now reflects all of its won accounts rather than an arbitrary slice.
  • The living-doc query truncation guard is now enforced (not just observed): a workflow step whose query returns more rows than its limit fails the run instead of silently producing a document over a truncated sample. When a generated living doc hits this, the self-tuning generator now recognizes it as a truncation failure and revises the recipe to aggregate over the full data (via SQL aggregation or a bounded lookup) — so a generated doc self-heals to correct figures rather than shipping partial ones.
Fixed
  • Turning a knowledge base document into a living document now reliably produces a runnable refresh workflow. The generated recipe is constrained to the tools and step types the living-doc engine actually executes, and a malformed draft returns a clear validation error instead of failing the request.
  • Living GTM docs (Voice-of-Customer, ICP Signal) now report won/closed counts, win rate, and revenue from your CRM deal records, cleanly separated from the conversation-corpus outcome view — no more conflicting "won accounts" figures between sections. Currency is dollar-formatted, quoted customer figures are no longer mis-flagged, and the reports no longer state industry/segment splits that aren't in the data.
  • Living GTM docs now state every figure exactly as computed — the narrative no longer rounds or approximates a number (e.g. "~5,600" for 5,904), so the prose always matches the underlying data.
Fixed
  • The divergence map now renders on the `search` action too. A prior fix pinned the synthesis model path for the enrichment briefs but missed the plain search action, so search could still route its internal-vs-market synthesis to a slower back-end that timed out and returned an empty divergence map. All external-search synthesis paths now use the same fast, dedicated model, so the fused internal-vs-market view renders consistently on search as well as the company/person/topic enrichments.
Added
  • knowledge_base.promote and knowledge_base.dismiss. Approve a proposed living-doc version (promote it to the current, cited version) or dismiss it, from the console or REST.
  • Living-doc subscribers. Read and replace the members/roles notified when a document has a new version to review (GET .../knowledge-base/:id/subscribers, PATCH .../knowledge_base/documents/:id/subscribers).
  • Saved views. Personal named filter presets for the Living Knowledge list: knowledge_base.list_saved_views / create_saved_view / update_saved_view / delete_saved_view.
  • Documents now carry an owner and an audience role, and surface whether a proposed version is awaiting review.
  • Two new `data.query` surfaces: `competitor_mentions` and `deal_score_weights`. competitor_mentions (one row per company + competitor) lets you build an internal win-rate-by-competitor read straight from your conversation corpus — no external search — bridging to deal outcomes on company_id. deal_score_weights exposes the learned deal-score model: which signals predict close-likelihood for your tenant (a factor→weight map) and how well it performs (holdout vs baseline AUC). Both are discoverable via data://schema and honor data-scope — competitor_mentions is company-scoped like the deal-grain surfaces, and deal_score_weights is a business-level admin/GTM read.
  • POST /agent-blueprints/:id/activate — activate or deactivate a workspace workflow (body { enabled?: boolean }, default true; creates the activation row when enabling one that was never activated). Powers the Living Knowledge "Activate" affordance on a living document whose producing workflow is off.
  • Living Knowledge documents now carry source_run_status (the producing workflow's most-recent run status) and workflow_active (whether that workflow is enabled), so the list can badge "Last run failed" and filter "Runs failing".
  • Two new Page components: `CopyButton` and `Dialog`. CopyButton copies a value (a literal or a bound cell, e.g. a recommended message) straight to the clipboard on click — the one-click "grab and paste" affordance. Dialog adds a trigger button that opens a modal rendering its child components, so a page can tuck supporting detail (quotes, a table, a chart) behind a click-in instead of showing everything at once. Both are client-side only — they run in the browser and invoke no server operation.
  • Industry: dropped the market-themes and divergence-map reads (industry://themes / industry://divergence). Added a raw Industry Signals read (industry.list_signals / industry_signal://list, REST GET /industry/signals) over the collected corpus - the source documents the sweep landed, filterable by domain, source, angle, sweep, and collected/published date, with a free-text search over title and body, and paginated. industry.refresh and the industry_run:// run-history reads are unchanged.
  • Sync a single knowledge-base document to Notion on demand, and open it in Notion. If your workspace has Notion Knowledge Sync connected, you can now push one document to Notion immediately — instead of waiting for the automatic sync or the hourly reconcile — and read its live mirror state: whether it has a Notion page yet, a direct open-in-Notion link, and whether the mirror is behind the current version. New endpoints POST /notion-sync/documents/:document_group_id/sync and GET /notion-sync/documents/:document_group_id (plus the notion_sync://documents/<document_group_id> read resource for MCP).
  • New `industry.get_signal` operation — read one collected market-signal document in full. The industry signals list now returns a short preview (snippet) per document to keep the feed fast; fetch a single document's complete text on demand by id via industry.get_signal (industry_signal://<id> on MCP, GET /industry/signals/:id on REST), so the detail view shows the whole article instead of a truncated blurb.
  • Two new `data.query` surfaces: `deal_stage_history` and `ref_deal_stages`. Query your CRM deal stage-transition history and the stage dimension directly, so you can answer where deals actually die (the stage a lost deal sat in right before it closed) and how long they spend in each stage — questions the current-stage deals snapshot can't. deal_stage_history is one row per stage change (deal_id, the raw CRM deal_stage id, changed_at); ref_deal_stages maps each raw stage id to its human label, funnel position, and won/lost/open class. Read data://schema for the columns and example queries.
  • New starter workflow: Market Intelligence Digest. A weekly living document built from your Industry signals — the market-signal documents the platform sweeps from the web, news, and SEC filings. It surveys the collected feed, drills into the documents most relevant to your business (and named competitors) in full, and writes an executive read + what's-moving + competitor-watch digest that auto-promotes each run. Fork it from the starter workflows and enable its weekly schedule (Monday 08:00), or run it on demand. See the new Industry signals guide (Using Amdahl, under "See what it found") for the full workflow, the API, and how agents read the feed.
  • Remove a single document from Notion — and keep it removed. If your workspace has Notion Knowledge Sync connected, you can now remove one knowledge-base document from Notion without turning off the whole sync: it trashes that document's Notion page and mutes the document so no automatic sync, hourly reconcile, or backfill re-mirrors it (the fix for a _living_ document that would otherwise come back on its next update). Re-syncing the document brings it back — Remove and Sync are a clean toggle. New endpoint POST /notion-sync/documents/:document_group_id/unsync.
Changed
  • Faster external search. Market/topic research (search + enrich_topic) now spends less time ranking and synthesizing results: the relevance-ranking pass is capped to the strongest candidates per source, the source fan-out closes out sooner, and the summary step works from a tighter set of citations — trimming response time while keeping the same broad source coverage.
  • External search responds faster. The relevance-ranking step in market/topic research (search + enrich_topic) now works from a leaner pool of top candidates per source, cutting the time spent scoring results. Source coverage is unchanged — every source still contributes — so you get the same breadth of citations, sooner.
  • External search responses now include per-stage timing. A search result carries an optional _timing breakdown (query enrichment, source fan-out, ranking, relevance gate, and synthesis — with synthesis further split into clustering and each summary pane). It's additive and safe to ignore, but lets you see exactly where a call spends its time when diagnosing latency.
  • Every Amdahl workflow now works goal-first and recursively. All shipped workflows — plus any you fork from them, and any generated from an uploaded document via "make it living" — now share one investigation method: anchor on the goal, tie claims to a measurable outcome vs a baseline, and treat a finding as a lead (drill into what causes it and how to act on it) rather than filling a fixed template. The discipline is centralized in one place, so it keeps improving across every workflow at once.
  • GTM Health Report is now outcome-first and adaptive. The standing GTM health report was rebuilt to reverse-engineer the questions that matter for _your_ business and test each against a real outcome — win rate, cycle velocity, deal size — instead of leaning on conversation-theme summaries. It now computes the funnel and where it leaks by stage, the drivers that actually predict a win (stakeholder breadth, champion presence, and more), an internal competitive read, and message performance — all from your own calls, emails, and CRM, with named accounts and verbatim quotes behind every finding. Competitive position and messaging no longer come back "not computable" and there is no external dependency to fail on; every dimension is derived from your corpus.
  • Living Knowledge provenance. A living document's source_workflow now carries the producing workflow's name and blueprint_id, so the "Fed by <workflow>" label can name and deep-link the workflow instead of showing a generic "an Amdahl workflow" label.
  • Living Knowledge ownership. Backfilled the accountable owner on documents created before the owner field existed (using the workflow's activating user, else the uploader), so the Owner field is populated rather than empty.
  • GTM Health Report now reports stage velocity (median time-in-stage, won vs lost, and the stage where deals stall) and win rate _by competitor_ (grouped by rival type, with any win-rate inversion called out) — not just a rival mention list. It no longer emits "TBD" for time-in-stage; when a workspace lacks stage-transition history it says so and falls back to the overall won-vs-lost cycle spread.
  • Pipeline Health Report now flags stalled stages using the median age of open deals per stage.
  • GTM Health Report now reliably includes stage velocity (time-in-stage, won vs lost) and win rate by competitor — these are now required outputs of the diagnostic rather than optional, so they appear on every run where the data supports them (with an honest coverage-gate fallback when it doesn't).
  • Living-doc workflows authored by the "make it living" generator are now data-field-aware: the generator knows the tenant's high-value CRM signals (champion / economic-buyer flags, close-likelihood + binding-constraint scores, per-stage dwell time, and competitor win-rate) and composes queries that use them — so a generated living doc produces the sharp cuts, not a generic theme summary.
  • GTM Health Report now always includes a stage-velocity cut (median time-in-stage, won vs lost, and the stage where deals stall) and a win-rate-by-competitor table — computed as deterministic steps so they appear on every run, not just when the agent happens to author them.
  • Living documents that are analytical diagnostics now fully regenerate their numbers each run instead of minimal-editing the prior version, so newly added analysis sections take effect immediately (a full_regen_each_run blueprint setting).
  • Several Living GTM Doc reports now read high-value CRM signals they previously ignored, so they produce sharper analysis:
  • Win/Loss Report — won-deal MEDDPICC differentiators (which qualification dimensions separate wins from losses) and win rate by named competitor.
  • Deal Qualification Report — the most systematically undercovered MEDDPICC dimensions, computed directly rather than guessed.
  • Account Tier Intelligence — tiers now weigh stakeholder quality (champion / economic-buyer presence, champion score) and 30-day engagement momentum, not just raw activity volume.
  • Weekly GTM Digest — flags stalled pipeline stages by median deal age.
  • Competitive Divergence Map — names the actual recurring rivals (not just a competition-present count).
  • ICP Signal Report — leads with the firmographics of _won_ accounts (who actually buys) and flags ICP drift vs the overall funnel.
  • Persona Voice Deep-Dive — the role mix now surfaces champion / economic-buyer counts per role level.
  • The natural-language SQL helper (data.ask) learned two new analysis plays: per-stage dwell/velocity (from the interactions temporal-stage surface) and win-rate-by-competitor (bridging competitor mentions to deal outcomes).
  • Living documents now self-heal. If a scheduled or on-demand living-doc run is cut short before it writes its document (e.g. a transient rate limit on a large workspace), the platform now automatically re-runs it (bounded) instead of silently leaving the document stale — and a retried run is nudged to work more economically so it finishes within budget.
  • External search is faster and its divergence map now always renders. The result-ranking step switched from an LLM scoring pass to embedding similarity, cutting a large chunk of latency off every search / enrich_topic call. That freed budget goes to the fusion synthesis: the internal-vs-market divergence map (the tenant-only signal a plain web search can't produce) previously timed out and came back empty on most calls — it now completes and ships populated.
  • Content Calendar links straight to your drafts. The Content Calendar workflow's page now opens the full drafts from your Knowledge Base — the drafts living document stays the single source of truth, and a "Read the drafts" panel on the page links out to it, so the schedule and the full copy never drift apart. The knowledge_base.upload API now also returns a console_url deep link to the uploaded document, so an automation can point back at exactly what it just saved.
  • External search's divergence map now reliably renders (and the call stays safely under the timeout). Follow-up to the previous rerank change: the internal-vs-market divergence map — the tenant-only signal a plain web search can't produce — was still coming back empty because the synthesis panes generated more text than they had time to finish. Their output budgets were trimmed so each pane completes, and the overall time budget was tightened so a call can no longer run up against the 60-second ceiling and return nothing.
  • Living GTM documents now tie themes to outcomes, not just volume. Five of the standing GTM living documents got sharper by grounding what they surface in won-vs-lost deal outcomes instead of raw frequency:
  • VoC Cluster Report now leads with the themes that show up in _won_ deals and ranks by win-correlation (with a loss-correlated risk section), instead of ranking by how often a theme comes up.
  • GTM Proof & ROI Library now leads with competitive-displacement wins — accounts you won _and_ where a rival was named — as the strongest reusable proof.
  • Champion & EB Voice Digest now includes a dedicated objections & pushback section mined from what senior stakeholders actually push back on.
  • Cross-Persona Divergence Brief now calls out which persona layers, when engaged, predict wins — so multi-voice content leads with the framing that closes.
  • GTM Health Report now quantifies the top jobs-to-be-done as coverage on won vs lost deals ("pain quantified on 41% of won deals vs 6% of lost"), instead of a free-text ranking. Each keeps the same honesty discipline — a rate is only stated once a bucket has enough closed deals; below that it reports raw counts and marks the signal thin.
  • Read your Content Calendar drafts right on the page. The Content Calendar workflow's page now bakes each piece's full draft into the calendar itself: click any day to open a dialog and read that day's pieces in full (multiple per day supported), with a link to open the canonical, versioned copy in your Knowledge Base. The separate "Read the drafts" button is gone — the content reads in place. The Calendar and Board page components (authored via the pages tool) gained two optional props for this: contentField (the row column holding a piece's full markdown body) and docUrlField (a per-row deep link to the living doc); when set, each item becomes clickable and opens the read-in-place dialog.
  • The GTM Priority Briefing now ranks by grounded confidence, not a guess. When the weekly briefing scores each priority by impact × confidence × urgency ÷ effort, the _confidence_ term is now set from real signals: each source document's own stated confidence (the GTM Health Report's score header, per-finding high/medium/low, and "thin/directional/not measured" honesty markers), whether two or more documents independently corroborate the insight, and the knowledge-base retrieval match score. A loud-but-thin signal can no longer outrank a quiet-but-corroborated one, and every priority shows why its confidence was set the way it was.
Fixed
  • Issues inbox filtering. GET /console/issues?category=... now accepts the app-internal issue categories (e.g. proposed_kb_version) alongside the pipeline quality-event categories, so filtering the inbox by one returns results instead of a 400 invalid_category. This unblocks the Living Knowledge "needs review" count.
  • Living Knowledge workflow activation now reflects execution reality. The workflow_active field on documents and the POST /agent-blueprints/:id/activate toggle read/write blueprint_schedules.enabled — the cron scheduler's source of truth — instead of the workspace_workflows curation badge. Previously the flag under-reported which workflows actually run, and "Activate" moved a badge without starting or stopping any scheduled run.
  • The Content Calendar page's "Read the drafts" link now points at your real Knowledge Base doc. The deep link back to the full drafts is attached on the same path the scheduled workflow actually runs through, so the page links to your living document instead of a guessed URL. The knowledge_base tool (both the API operation and the MCP coarse tool) now returns a console_url for the uploaded document, so any automation can link back to what it just saved.
  • External search's fused brief (market summary, customer voice, and the internal-vs-market divergence map) now actually renders. The synthesis step was opening streaming connections that never completed in the production environment, so every search / enrich_topic call fell back to an empty brief. The synthesis calls were switched to a non-streaming request that completes reliably, so the market summary, your customer-voice pane, and the divergence map come back populated.
  • External search's divergence map renders more reliably. Follow-up to the non-streaming synthesis fix: the fusion panes now complete, but under concurrent load one pane occasionally ran a hair past its time budget, so the full brief (market summary + customer voice + divergence map) came back complete only intermittently. Their time budgets were given a small amount of headroom and their prompts trimmed slightly so each pane starts and finishes sooner — making the complete brief, including the internal-vs-market divergence map, render consistently.
  • External search's divergence map now renders reliably on every call. The final fix in the fusion-synthesis chain: the internal-vs-market divergence map (the tenant-only signal a plain web search can't produce) was completing only intermittently because all three synthesis panes generated at once and competed for throughput. The panes now run two-at-a-time — the divergence map gets a dedicated slot while the two summary panes share the other — so the divergence map finishes and comes back populated consistently.
  • Documents, pages, and artifacts produced by a workflow run are now attributed to the person who triggered the run. Previously anything a headless/scheduled workflow authored was credited to the workspace owner (the identity the runner acts under), so a report someone else kicked off showed the owner as its author. Outputs now carry the actual triggering user as their creator, while owner attribution is preserved when a run has no human trigger.
  • The Content Calendar workflow can now be forked into your workspace. Forking it previously failed validation because its tool allowlist named an internal coarse-tool action (context.entries_list) instead of the canonical operation id (context_entry.list); the entry is corrected, so POST /agent-blueprints/fork (and the blueprints fork action) now succeed. Behaviour of existing runs is unchanged. A new starter-forkability test pins every shipped starter's tool allowlist against the operation registry so no starter can ship unforkable again.
  • The market-intelligence divergence map now renders reliably on every search. External search and the company/person/topic enrichment briefs synthesize their internal-vs-market "divergence map" — the view that fuses your own CRM and call corpus against the public web — on a fixed, dedicated model path. Previously that synthesis could be silently routed to a slower back-end that couldn't answer within the call's time budget, so the divergence map came back empty. It now runs on the fast path every time, so the fused view (the part generic web search can't produce) shows up consistently.
  • The "open the living doc" link on a Knowledge Base upload response now resolves to the document instead of 404ing. The console_url attached to a knowledge_base.upload result (on both the operation and the MCP coarse tool) was built from the document's document_group_id, but the workspace KB detail route resolves a document id — so the link opened "Document not found". It now uses the freshly-written version's document id, so a living-doc → page workflow (e.g. the Content Calendar) links straight to the current version of its full drafts.
  • Five more workflow starters are now forkable. Following the content-calendar fix, the plan-and-draft-window, GTM diagnostic, positioning scorecard, Substack thought-leader newsletter, and content-calendar-routine starters each listed a tool in their allowlist that was not a registered operation (e.g. web_search, data.explore, and internal run-lifecycle controls), so forking them into your workspace failed the allowlist validator. Their allowlists now name only canonical operations, and a guard test (which was previously a no-op) now actually checks every starter, so each one can be forked.
  • "Your workflows" now lists every workflow that's actually running. A scheduled workflow only appeared under "Your workflows" if it had been switched on through the in-app activation toggle. Workflows put on a schedule another way — for example a recurring living-document report — kept running on their cron but were shown under "Library" as if they were still just an un-adopted template. The Workflows page now decides what's active directly from the schedules that fire, so anything running for your workspace shows up under "Your workflows".
Added
  • `data.query` on the theme clusters surface now supports `as_of` backtests. A SQL read like SELECT label, velocity FROM clusters … with an as_of date now resolves the cluster generation that was live on that date (via the warehouse clusters_as_of table-function) instead of being refused — so a blueprint that reads themes with raw SQL backtests cleanly. The append-only theme_lineage history surface still requires as_of reads to go through data.cluster_search / data.cluster_detail.
  • Living GTM Docs — 18 workflows + matching pages. Eighteen new Amdahl-shipped workflows generate versioned, human-promoted living documents in your knowledge base on a cadence — VoC cluster report, champion/EB voice digest, pipeline health, deal qualification, win/loss, ICP signal, account tiering, positioning scorecard, persona voice deep-dives, cross-persona divergence, competitive divergence map, ecosystem map, topic research briefs, GACCS content briefs, AEO health, the weekly GTM digest, the GTM proof & ROI library, and the meta GTM priority briefing. Each ships with a live page template (browse via page_template://list / author with pages.create_from_template) so the same intelligence is available as a live console dashboard, not just a narrative.
  • Pages can read clusters and the knowledge base. Page declared queries now support cluster_search and kb_search sources (in addition to sql and artifacts), so a page can surface conversation themes and reference-library matches live.
Fixed
  • External search now draws on every source it advertises, and returns faster. Market/topic research (search + enrich_topic) now fans out across the full set of registered sources — including the developer and review platforms (GitHub, Hacker News, dev.to, Medium, Stack Overflow, G2, Capterra, TrustRadius, Product Hunt) alongside web, Substack, and YouTube — so those citations actually show up in results. The same change removes a bottleneck in the relevance step that was roughly doubling response time on these calls.
  • Theme-based Living GTM Docs now backtest correctly. The Voice-of-Customer, Weekly GTM Digest, and GTM Priority Briefing living documents read your conversation themes through the point-in-time theme index, so running one as an as-of backtest reconstructs the themes that were live on that date instead of failing on the theme step. Their scheduled (live) runs are unchanged.
Added
  • New industry intelligence surface. A daily sweep gathers what the wider market is discussing (web, news, and SEC filings) and maps it against what your own customers raise on calls. Read it via the new industry.get_divergence (the four-axis market-vs-customer divergence map) and industry.list_themes (the clustered market themes) operations, or trigger a fresh sweep on demand with industry.refresh.
  • Substack and YouTube are now searched in external research. Founder newsletters, talks, and demos are strong go-to-market signal, so external_search (the search and enrich_topic actions) now covers Substack and YouTube alongside web, news, LinkedIn, and Crunchbase.
  • More platforms in topic and market search. Topic and market searches now also pull from Medium, Hacker News, dev.to, GitHub, Stack Overflow, G2, Capterra, TrustRadius, and Product Hunt (alongside the existing Substack and YouTube sources), giving broader coverage of practitioner writing, developer discussion, and buyer-intent review sites. New platforms are added through a single registry, so the source set will keep growing without changes to how you search.
  • agent_blueprint.list entries now include forked_from_amdahl — true when a workflow was forked from an Amdahl starter (via "Use template"), so you can tell an Amdahl-derived workflow from one you built from scratch.
  • Trigger and watch an industry sweep over MCP. The new industry tool's refresh action kicks off a "What's Going On In The Industry" sweep on demand instead of waiting for the daily run, and returns a run_id. Poll the new industry_run://list + industry_run://<id> reads to watch the run to completion and read its per-stage step log; read industry://themes / industry://divergence for the clustered market themes and the market-vs-customer divergence map.
Changed
  • External search is faster and no longer times out on long calls. Broad web and news results now run on the faster search backend (when configured), removing the slow source that previously gated every fan-out. The per-call deadline_ms is also clamped so the full pipeline (search + synthesis) stays under the tool-call ceiling — a large deadline_ms no longer causes the whole call to time out and return nothing; slow sources just surface as incomplete instead.
  • External search synthesis is faster and less likely to time out. The market/internal synthesis step now works over a tighter, top-ranked set of sources, so the brief comes back sooner — particularly on broad topics where the wider source mix previously pushed synthesis past its budget.
Removed
  • Reddit and X/Twitter removed from external research. Neither has a serviceable data provider, so external_search no longer searches them; the enrich_person action no longer attempts to resolve an X handle.
Fixed
  • External search no longer drops sources under load. When many searches run at once (e.g. competitor/market research alongside the industry sweep), requests are now smoothly rate-limited and automatically retried instead of failing, so a burst costs a little extra latency rather than missing results.
  • Embedded Pages now load on any site. A live Page embed (the <iframe> snippet from a Page's Embed button) fetches its data cross-origin, which was being blocked unless the embed was hosted on an Amdahl domain. The public page-embed API now permits cross-origin requests from any origin, so a pasted embed renders wherever you put it.
  • Fixed the embed signing-secret screen. Creating a signing secret showed a blank value and the secrets list showed an undefined prefix, due to a mismatch between the API responses and the console. Both now return the correct shape, so the one-time secret and the prefix display correctly.
  • `data.query` MCP calls now capture the question that triggered them. Previously, a data query (raw SQL) call could silently drop the verbatim user message even when your client supplied it, because that action logs through its own path rather than the shared one. The resources, social, pages, notifications, industry, and blueprints tools now also accept the same optional context so a client populating it gets full coverage across every tool, not just data / context / knowledge_base / external_search.
Added
  • Run and schedule Workflows directly from MCP. The blueprints coarse tool gains a run action (a one-shot headless run) alongside create_schedule for recurring cron runs, gated by the new blueprints:execute scope on the customer-agent key bundle. A workspace MCP key can now launch and schedule its own Workflows without any broader delegation grant.
  • Author a Page from a template over MCP. The pages coarse tool gains a create_from_template action that materializes a vetted, Amdahl-shipped page template by template_slug; browse the available templates via the page_template://list resource first.
  • Read drafted content pieces back over MCP. A new content_piece:// read scheme (content_piece://list + content_piece://<id>, gated by artifacts:read) surfaces the content pieces a content-calendar Workflow drafted, so an agent can review a run's output without leaving MCP.
  • agent_blueprint.list now returns is_active and source on every entry, so you can tell which workflows are active in a workspace and whether each is an Amdahl-managed default (source: "amdahl") or your own (source: "custom").
  • New workspaces start with the GTM Health Report workflow active by default.
Fixed
  • External search now returns social results again. Reddit, X/Twitter, LinkedIn, and Crunchbase sources are served via web search, so external_search (search and the enrich_* actions) surfaces social signal instead of coming back empty.
Added
  • Action page component + the pages.invoke_action operation, so a Page can now do something, not just display: an Action button invokes a write/compute operation (e.g. artifacts.update, agents.run_blueprint) as the clicking viewer. A button can only invoke an op the page declares, and the viewer must independently hold that op's scope — so a Page can never become a way to call an operation a viewer couldn't call directly.
  • pages.create_from_template operation. Author a new workspace page from a vetted Amdahl page template (a catalog-only spec) by slug — so the in-app copilot can stand up a page (e.g. a content-calendar page) and, paired with a workflow schedule, set it to auto-update on a recurring cadence.
  • { $row: 'field' } Action input. A page Action button can now act on the row the viewer clicked — its declared inputs resolve from the clicked row, so per-row buttons (move this piece, archive this row) no longer need a hard-coded id.
  • Page Action buttons can now invoke `workflow` operations (e.g. agents.run_blueprint), not just write/compute ops — so a Page button can kick off a workflow run, not only edit a row. The rule is now simply: a button may invoke any op that _does_ something (write / compute / workflow); a read stays the page's display channel. The same boundary holds — the op must be declared on the page and the clicking viewer must independently hold its scope (so a "Run workflow" button needs workflows:delegate).
  • Page Action buttons can also target external providers via an external:<id> address — a pluggable seam for actions that reach out (post to a channel, push to a CMS, fire a webhook), gated by the provider's required scope. A reference external:echo provider ships so the path is live; real providers are added behind it.
  • The Content Calendar page template gained two buttons — "New piece" (creates a placeholder content_piece) and "Run routine" (fires the new `content-calendar-routine` workflow on demand) — plus that workflow itself, a scheduled (weekly, off by default) routine that plans and drafts the upcoming window. Enable its schedule to keep the calendar full hands-off.
Fixed
  • Page embed signing secrets now load and manage correctly from Settings -> Embedding in the console. Listing, creating, and revoking a workspace's page-embed signing secrets works again (the surface previously failed with a "business_id is required" error).
Added
  • Calendar and Board page components, plus an artifacts query source, so a Page can render your workspace content (e.g. content pieces) as a live calendar or kanban board over the Postgres artifact store — not just the warehouse sql source.
  • A ready-made Content Calendar page template (content-calendar) that pairs the two views over your content_piece artifacts. Fetch it via pages.get_template and author a Page from it.
  • Positioning experiments. Read how your positioning claims perform — what buyers echo back versus what actually wins deals — across three new surfaces. Query the data directly: data.query now addresses two positioning surfaces, positioning_claims (your claim inventory: text, label, category, status) and positioning (per-claim performance: leading buyer-reception lift and matched-control lagging win-rate lift), discoverable via data://schema. Run the new Positioning Scorecard workflow for the labeled "what we say vs what lands vs what wins" read, including the inversion where the story you lean on most is the one that loses. Or author a live Positioning Experiments page from the new template (claim inventory, confidence-tier breakdown, and a reception-vs-outcome chart). The win-rate signal is a matched-control estimate (a correlation, not proof) — the surfaces are built to read claim _ordering_ over absolute numbers and flag a low-coverage signal as directional.
Changed
  • Positioning experiments now separate measured messaging from candidate positioning. Every claim carries a source: mined_llm claims are pulled from what your team actually says on calls (their lift is real messaging performance), while seed claims are hand-authored candidate positioning the engine matches by meaning (their lift is a "would this land" test, not a performance review). The Positioning Scorecard workflow and the Positioning Experiments page now keep the two in clearly separate sections and never present a seeded claim's numbers as measured performance, and the data.query schema guidance teaches the distinction so the assistant interprets it correctly in chat too.
Added
  • Connection data summaries. Each connected source now exposes a glanceable, per-connector snapshot of its own synced data - counts and totals (deals and pipeline value, meetings, messages, tickets, pages), the most recent items, and a weekly activity sparkline - at GET /connections/:id/summary and the connection://<id>/summary MCP resource. Computed live from your synced data, it shows what is unique to each connector type.
Changed
  • Relationship intelligence now lives directly on the data surface. Questions like "who is the champion or economic buyer on this deal", "what did a specific stakeholder actually say", "who do we keep losing to", and "which accounts are evaluating a competitor" are answered with data query / explore over the stakeholder columns folded onto the interactions surface (is_champion, is_economic_buyer, champion_score, role_level) plus the deal_qualification champion / economic-buyer / competition dimensions — taught in the schema explorer and the GTM routing guidance so the agent reaches for them automatically.
  • The data tool's schema catalog now advertises the native record_type column (call / email / meeting) for filtering interactions by category. Category-filter prompts and examples use it instead of the older interaction_type remap, so questions like "emails about pricing" or "calls per company" produce clearer, more accurate SQL.
Removed
  • Removed the include_knowledge_graph option on data cluster search. It read from a decommissioned dataset and added no signal beyond the stakeholder columns now folded onto the interactions surface, so the tool no longer accepts it.
Fixed
  • data tool: cluster_detail now accepts the source_id returned by cluster_search verbatim. Previously the cluster_id argument only accepted a number, so drilling into a theme failed with "Cluster not found" because theme ids are opaque strings — pass the source_id straight through and the drill-in works.
Added
  • A public changelog at docs.amdahl.ai/changelog - a searchable, branded timeline of what's shipped, refreshed automatically with each release.
  • Notion Knowledge Sync — one-way mirror your Amdahl knowledge base into a Notion database in your own workspace. Connect Notion over OAuth, pick a parent page, and every promoted document syncs into Notion automatically, with an hourly reconcile that backfills and self-heals drift. Configure / monitor it over REST or read its status + activity ledger over MCP (notion_sync://).
  • The assistant can now draw on more of your data directly for richer answers: the deal stage as it stood at the time of each conversation, theme insight summaries and headline hooks, theme lifecycle and lineage over time, and additional per-company deal-qualification detail.
  • The assistant now answers deal-stage and pipeline-funnel questions at the deal level with human-readable stage names ("Discovery", "Closed Won", …), win/loss outcome, and true funnel ordering — instead of raw internal CRM stage codes — so per-stage pipeline value, win rate, and funnel breakdowns come back clean and correctly ordered.
  • The assistant can now name themes directly when tracking how they evolve over time (a stable theme name now travels with each theme's history), and deal-stage / pipeline-funnel questions now return human-readable stage names for Pipedrive-based workspaces too — so theme-trend and funnel answers read cleanly across more CRMs.
Changed
  • knowledge_base.upload: a document's description is now its stable purpose ("what is this document for"). When you append a new version to an existing living document, the purpose is carried forward automatically and any description you pass is ignored — record per-version "what changed" notes in version_changes instead.
  • Internal cleanup: retired the legacy V1 cluster-evidence channels behind external research. The customer-voice / divergence-map pane now sources cluster evidence entirely from the V2 pipeline — no customer-visible change, since these channels already returned empty once the V1 pipeline was decommissioned.
Fixed
  • The GTM health report now reads correct, human-readable deal stages from your CRM (e.g. "Discovery", "Research", "Closed Won") instead of raw internal stage codes, and no longer splits a single account across multiple stage rows.
  • Asking the assistant about themes, deal stages, or deal qualification is now materially more reliable. It reads the correct underlying fields (so theme-lifecycle and stage questions no longer fail on some workspaces), keeps the "how complete is this deal's qualification" and "how likely is it to close" scores distinct, and stops surfacing internal placeholder columns that don't apply to your data.
  • Fixed "Make it living" on a Knowledge Base document failing with a schema-validation error. The synthesized refresh workflow now always declares a valid output, so turning a static doc into a self-refreshing living doc succeeds reliably.
  • Fixed external_search (search / enrich_company / enrich_topic) dropping its web results and competitive divergence map. The web source's underlying search call was rejected by the model API; it now succeeds, so the divergence map and web summary come back reliably.
  • Knowledge base search and other embedding-backed features now reliably use the current embedding model. Previously, newly uploaded documents could fail to become searchable, and semantic search could silently fall back to keyword-only matching.
  • Restored the internal-evidence keyword aggregates in external research (the customer-voice / divergence-map pane) after the V1 pipeline decommission — they now read the live V2 unified view instead of the dropped V1 dataset.
Added
  • Blueprints - shareable, forkable recipes an agent reads and walks step by step. Author, validate, and fork via the blueprints tool family and REST; seven starter blueprints ship out of the box.
  • Connections - a unified connector catalog and connect / disconnect surface spanning CRM, calls, comms, docs, support, and social (X / LinkedIn). Poll connection health and sync-run history over connection:// reads and REST.
  • External search fuses your CRM + call corpus with live market sources into a single brief (the divergence map). Four actions: search, enrich_company, enrich_person, enrich_topic, with an opt-in SSE event stream.
  • Notifications - agents can email workspace members (member-only, rate-capped, idempotent) via the notifications tool and POST /notifications/email-member, and read the send ledger.
  • Pages - workspace-authored, data-backed UIs composed from a catalog of components (tables, charts, stats, signal maps) bound to your own queries. Author + validate via the pages tool family and REST, publish, and embed.
Changed
  • The performance.* operations were renamed to social.* (clean break, no aliases) for social account tracking and engagement reads.
Removed
  • Retired the customer-facing artifacts REST API (/artifacts*) and its public read endpoint. Pages and Blueprints are the structured-output primitives now; artifact webhook events and data models are unchanged.
Added
  • Generic Agent Runner. New agents.* tool family: agents.start, agents.status, agents.resume, agents.cancel, agents.list, agents.profiles_list.
  • Server-Sent Events stream for agent progress at GET /api/platform/v1/agents/:session_id/stream. Emits state and progress frames, :ping keepalives every 15 seconds, auto-closes on terminal status.
  • Three built-in agent profiles: content_writer (end-to-end content creation, 25-turn budget, emits content_piece artifacts), researcher (rigorous research with citations, 20-turn budget, emits research_report artifacts), copilot (general-purpose assistant, 15-turn budget, optional artifact output).
  • Three pause types on awaiting_input sessions: approval, question, continue_or_finish. Each carries a JSON Schema that agents.resume validates against.
  • Consumer docs tree at /docs/consumer/ with auto-generated API reference covering every tool, scope, webhook event, and data model.
Changed
  • Every agent tool call is now written to platform_audit_log with the caller's identity and correlation id, matching the audit trail direct REST and MCP calls already produced.
Added
  • platform_audit_log table. Every tool invocation across REST, MCP, and agent sessions is recorded with actor id, business id, tool id, inputs hash, status, duration, and correlation id.
  • audit_log.query tool for reading the audit trail. Scoped by business; platform admins can query across businesses.
  • Response-side redaction layer. Any field classified as sensitive is replaced with [MASKED] and paired with a <field>_masked: true sentinel in the response body.
Changed
  • Error responses now include X-Correlation-Id on every status code, not just 5xx, so failed requests can be traced end to end.
Added
  • Webhook extensions. POST /api/platform/v1/webhooks/:id/test fires a single test.ping delivery without affecting production aggregates. GET /api/platform/v1/webhooks/:id/deliveries returns the delivery history with status filtering and pagination.
  • is_test column on platform_webhook_deliveries to separate test fires from production traffic.
  • X-Webhook-Test: true header on test deliveries so receivers can branch on synthetic traffic.
Changed
  • Webhook delivery retry policy codified at 3 attempts with 1s and 10s backoff intervals; after the third attempt the failure is recorded and failure_count is incremented on the webhook.
Added
  • scope_mode column on API keys and OAuth clients, with per-tool scope enforcement. Tools whose declared scope is not in the caller's effective scope set are silently dropped from the runtime tool list.
  • Scope reference page at /docs/consumer/api-reference/scopes.md, auto-generated from the tool registry.
Changed
  • Role defaults tightened. New keys default to the minimum scope required for the role; existing keys retain their prior effective scope via the grandfather rule.
Added
  • Artifact unification. Bidirectional triggers between platform_artifacts and the legacy writing_samples, customer_posts, customer_testimonials, and style_collections tables. Reads from either side return identical data; writes propagate automatically.
  • platform_artifact_versions append-only version audit trail. Every write to platform_artifacts inserts a corresponding version row.
  • artifacts.get tool now supports include_versions: true to return the version history inline.
Changed
  • Artifact writes must supply expected_version for concurrent-edit safety. Mismatches return 409 version_conflict with the current version in the details block.
Added
  • Platform API v1 initial release. Base URL https://api.amdahl.com/api/platform/v1. Four-tool architecture: data.*, context.*, artifacts.*, plus the supporting surfaces for sessions, webhooks, scopes, and OpenAPI docs.
  • OpenAPI 3.1 spec at /api/platform/v1/openapi.json, regenerated on every release.
  • Canonical error envelope with code, message, and optional details.
  • Global per-IP rate limit of 100 requests per minute on /api/platform/v1/*.
  • OAuth 2.0 dynamic client registration per RFC 7591 at POST /oauth/register, with its own tighter limit of 5 registrations per minute per IP.
Changed
  • Nothing; this is the baseline.