Changelog
RSSAll 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(andGET /eval-runs/{id}/gate) carries two new fields:reasoning, the judge's rationale for the copy you submitted, andcritique, 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.critiqueis 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
interactionssurface insearch.fields,search.query,data.query,data.askanddata.aggregate:speaker_ambiguous(does this row hold more than one voice),speaker_count(how many), andturn_speaker_keys(the roster of identity keys, readable viaUNNESTbut not filterable). A row on this surface is a grouped TURN whosespeaker_namenames 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 = FALSEscopes 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. UseCOUNT(DISTINCT k) FROM interactions, UNNEST(turn_speaker_keys) AS kto 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: trueonPOST /evals/run(MCP: the same param on theevalstool'srunaction) and one public-web fan-out runs — the query planned by a lightweight model from your submission — and its snippets join the evidence pool taggedexternal, 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 theexternal_search:executescope — refused without it rather than silently dropped — andexternal_cap(default 15, clamped 1–25) bounds the retrieval. The run's evidence disclosure gainsexternal_status,external_query,external_sourcesandexternal_quotes, and the progress trail reports the fan-out asexternal_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
interactionssurface 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_rowsandcorpus.speaker_fallback_rowssplit the rows on both search lanes; thespeaker_grainblock addsmultiplicity_source("stamped"/"overlay"/"mixed"),stamped_rows,fallback_rows,disagreed_rows(rows where the recorded verdict and the reconstruction differed — the recorded one is served) andstamped_columns_available. Deal health'sspeaker_grainand the testimonials picker'sspeaker_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_speakersroster 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_keyscarriese:<email>andn:<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 aree: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_grainenvelope when a speaker column appeared in the returned rows. They now also carry one when the speaker column reached the SQL only through ametricor afilter— for examplecount_distinctofspeaker_namegrouped 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_idalongside the speaker columns, which aGROUP BYcannot do without changing its grain. It now points atWHERE speaker_ambiguous = FALSEand 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_personais 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 thantotal_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/querywithmode: "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 unrelatedquerywas added. Agroup_bywith 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
querysent to thefilterlane is not read by that lane, andasync/max_subqueriesdo not apply to thesemanticlane; all three are now disclosed rather than dropped silently. Notably, anasync: truerequest 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.promptcarried the eval's own suggested reusable prompt. It is nownull, per the documented contract. The suggested prompt is unchanged onimproved.prompt, and a simulated message on a prompt-only run still appears, still flagged bysimulated_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
interactionssurface is a grouped turn — several utterances folded into one row and stamped with its first member's name — sospeaker_name,speaker_title,role_level,is_champion,is_economic_buyerandchampion_scorecan 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 carryingspeaker_attributionsaying whether anybody had verified it; through a public embed link the identical row arrived bare. It no longer does:/api/embed/pages/.../renderruns the same turn-grain verification, stampsspeaker_attributionon every row, and returns aspeaker_grainenvelope 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
runDeclaredQueriesand 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_countcounts 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 carriesspeaker_grainplus per-personambiguous_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 reportstruncated: trueand 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-searchpersona_countsaggregate, the listening-lens documentauthorand transcript blob, and the shared deal-attached-voice SQL fragments (whoseQUALIFY 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 MCPdatatool, so whether it is already covered depends on which buildSALES_DIGEST_MCP_URLpoints at — undetermined, not assumed either way. - Quote panels stop hiding the second person in the room. A row on the
interactionssurface 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) splicedQUALIFY ROW_NUMBER() OVER (PARTITION BY speaker_name ORDER BY quality_score DESC) <= 3into 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 reportedno_turn_keyand 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 25returned 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_exhaustedsays 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_nameon 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 asmatches: []— indistinguishable from a genuinely unknown person. It now reads the atomicutterancestable, 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_countnow 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 labelledspeaker_attribution: 'turn'. - Internal lens documents stop inventing an author.
ANY_VALUE(speaker_name) AS authorpicked 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 carriesspeaker_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-wideDISTINCTwith no turn list to verify, and moving it to the atomic table would silently change its denominator (unlike its two siblings it carries nointeraction_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_personais 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 carriesspeaker_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.aggregateMCP verb, whosegroup_byallowlist accepts all nine speaker columns from a model at runtime while the column catalog it reads describesspeaker_nameas "Name of the speaker" with no turn-grain caveat; theaccount_tier_intelligenceengagement aggregate, whosehas_championgate andexecs_engagedcount decide which accounts a seller works; thechampions/economic_buyerscurated search templates, whichGROUP BY speaker_namebehind a stamp-keyedWHERE; the evals cohort floor, whoseCOUNT(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.runand its read surface), and Agents (Chat, the agent library, Routines). The monthly verb-cap families behindquota_exceededare likewise listed correctly assearchandevals. 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
scopeonPOST /evals/run(and the MCPevalstool'srunaction) takes typed filters —{ surface, field, op, value }, ANDed, up to 25 — over the same field vocabularysearchadvertises, so _"grade this draft against what executives at closed-won accounts actually said"_ is one call. Filters may mix theinteractions,dealsanddeal_qualificationsurfaces in a single scope, whichsearch.querycannot 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 fromGET /search/fields(search_field://list, or thesearchtool'sfieldsaction) — 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 existinginputs.audienceCOMPOSE 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 ascope_resolvedstep in the run's progress trail (progress.steps[]oneval_run://<id>), carrying thereason, a writtenmessageto show a person, anddetailwhere 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, whereaudiencealready is.lookup_failedmeans 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 resolvedinputs.audienceis still in play, which is gated on its own path and survives. Passallow_thin_evidence: truewhen the narrow cut is the question: the run grades it and the step carriesbelow_floors: truebeside 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 noscopebehaves exactly as before. - A malformed scope is refused, never silently dropped. An unrecognised key (
operatorforop,columnforfield) or more than 25 filters comes back asinvalid_argumentfrom theruncall 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 bareallow_thin_evidenceall grade unscoped and all recordnot_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
searchboth read — no spelling of it works on any surface. Cut onrole_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
stageattribute 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
interactionssearch surface — filterable, groupable, and published insearch.fieldswith its full value vocabulary. It answers a question the API previously could not: whendeal_stage_status,deal_stage_labelanddeal_amountcome 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 arenulland"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_membersend 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 ofLabel: valuelines:chartdraws a horizontal bar chart sized against the largest value in the block (a ranking), andmetricsdraws KPI cards where a| +12%caption renders green and| -3 ptsred. 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 reads194 (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_normalizedandindustry_sourceon theinteractionssearch surface. Group or filter byindustry_normalizedfor a canonical industry bucket instead ofindustry, which is a raw passthrough of your CRM's and the enrichment provider's different spellings — so a plainGROUP BY industrysplits one industry across rows and undercounts it. On a corpus we measured,computer softwareandCOMPUTER_SOFTWAREcame 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 inGET /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.
accountwas single-valued and did double duty as *who the draft is going to* and *whose evidence to retrieve*. Nowaccountstays 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-evalmoves toeval_version2.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_scorechanged meaning at 2.14.0 andverdictchanged 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 aversion_boundaryblock naming the transitions crossed, anddelta_withheld_reasongains a sixth valueeval_version_boundary. The two crossing kinds behave differently: across a payload-meaning bump onlyscore_deltais withheld andsubmitted_score_deltasurvives; 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
LIMITto 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 yourlimit, detect the overflow, and slice the extra row off, so row counts are unchanged and the flag means what it says. Therow_count == limitheuristic people used instead is now a false positive on a population that lands exactly on yourlimit— readtruncated. One deliberate exception: atlimit: 1000, the engine's own maximum, there is no room to ask for a row past it, so a full result always readstruncated: 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 (notfalse) 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 declaredlanding_copygate run was graded on all five rubric lines and reported the excluded line insidedimensions_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.updatecan 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 —
filterandfuzzyread the warehouse,semanticreads 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 reportsstore,grain,row_count, whether yourfiltersactually reached the store (filters_applied), whether rows carry fullcontentor the mirror's truncated preview, andempty_reason, which separates an honestno_matchfrom aread_failed. The semantic lane degrades to a theme index — a different row shape — when the vector path is unavailable; that now readsdegraded: truewithfilters_applied: falseinstead 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.
interactionsonly. Available on thefilterlane, and onsemanticwithhydrate: true; refused onfuzzy, which writes its ownWHEREclause 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
contentpluscompany_name,speaker_title,is_championand 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_countalongsidecorpus.row_countshows where the rows went. Capped at 200 distinct calls, reported ascorpus.truncatedrather than silently sliced. - Semantic matches also return `parent_interaction_id`. The two lanes'
interaction_idwere 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_idjoins tointeractions.parent_interaction_idand 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,audienceandhydrategets 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), anddated_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 thesemanticlane, 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 abasis_reason, and their numbers are nulled or zeroed as unmeasured, never as measured zero.matched_setmeans the block describes your whole match set;returned_rowsmeans 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 upclose_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
filterscarry a range ontimestamp/occurred_atthe 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 asgaps_status: "unavailable", distinct from"ok"with an emptygapsarray — 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 — sorow_countwas1and the field was structurally unreachable on every aggregate ever run: acountover an empty window came back as{"count": 0}with nothing explaining it. A bare aggregate over an empty population now carriesempty_reason: "no_match". Aggregates with agroup_bywere always fine. - All of this is additive. No existing field changed shape, name, or meaning, and a caller that ignores
coveragegets exactly the responses it got before. - `search` has a new `lexical` mode — exact terms, ranked by how rare they are. The existing
semanticmode 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 forlexicalwhen the wording matters andsemanticwhen the meaning does;autopickslexicalon 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) andcorpus.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 wasscore, which is unbounded —1.50and8.97gave 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 reportscorpus.min_relevance(the bar used) andcorpus.weak_matches_filtered(how many were removed), so a short result set is never unexplained. When every match was filler the reply saysempty_reason: "weak_matches_only"— which means "nothing relevant was said", a different and weaker claim thanno_match's "nothing was said". - A quoted phrase now scopes the search to what you quoted. Asking
did anyone mention "data residency" concernssearches 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 returnnext_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) returnscreation_disabledfor non-staff callers unless the deployment setsWORKSPACE_CREATION_ENABLED=true, andGET /workspaces/minereportscan_create_workspace: falseaccordingly. 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, andotheralongsideexecutiveanddeveloper(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 = TRUEas 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 judgedTRUEreproduces only about 54% of them, so aTRUEis a pointer to a quote worth reading, not a certified label. Coverage, tri-state (NULLis notFALSE), inherited-recall and does-not-predict-deal-outcome guidance are unchanged. - The
account_type_normalizedfield description (served onsearch fields/search_field://listanddata://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 ondata.queryor the NL-to-SQL lane, becausesearch.query's typedfiltersare 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.sqlis 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_failedonGET /eval-runs/{id}/gatecarries each line's full text, because it travels without a reasoning field;report.findings.failed[].dimensiononGET /eval-runs/{id}/reportcarries the stem only, because there it renders as a lead-in to that line'sreasoning. 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_onlyinstead of the sameoka recipient-backed run reports.account_namealways 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 onokfor that population,reference_onlyfor the other. Eval version2.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
statusat the root, thatgateis nullable and a null is not a fail, or that a compare reportsdelta_withheld_reasonwith six typed values when it declines to emit a delta. Reading the absence ofscore_deltaas "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_setandcandidate_setare read as the single scalar the wire carries (the pin origin's run id) instead of being fetched whole, andtarget/as_ofare no longer read at all. No field on the lean row changed — this is the query underneath the same response.?include=fullis 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
totalas approximate for display; page withlimit/offsetand 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_tieris the real defect: a covering claim backed the sentence with another customer's words.second_person_undeclaredis weaker and frequently benign — the writer simply declared no claim for an ordinary sentence. The two buckets partitionsecond_person_unlicensedand both now ride on the improved-message facet. Nothing else changes: the policy is stillflag_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 carrysecond_person_unlicensedwithout 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 taggedOURS, 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_versionbefore averaging, trending or thresholding storedoverall_scorevalues, or readverdict.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_RULEnow rides both rewrite and advisory: paraphrase into your voice, at most one short quoted phrase, grounding stays inimproved_message_claimsvia 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-evalretrieved 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_versionbefore averaging, trending or thresholding storedoverall_scorevalues. 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_idcolumn on theinteractionssurface 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 viadata.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 useparent_interaction_idwhen you need an id that survives regrouping. - `search` hydrate — the
hydratedescription on the MCPsearchtool 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 atcorpus.hydrated_grainfor 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.queryschema, the MCPsearchtool, and thedata://schemafield catalog for the three affected columns, with guidance to read speaker fields from the atomicutterancessource 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
hydratedescription told callers to comparecorpus.match_countagainstcorpus.row_count. That delta does not measure what it reads as: the semantic lane's RPC takes alimitand applies no similarity threshold, somatch_countis how many nearest neighbours were pulled and trackslimit, not how many things genuinely matched. The description now names the fields that do answer it —corpus.unresolved_matches(matches with no turn) andcorpus.collapsed_matches(matches sharing one turn) — says to read them directly instead of inferring a delta, and states plainly thatmatch_countis bounded bylimit. - `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 MCPsearchtool and thesearch.queryschema now say to readhydrated_grainfirst. - Corrected the
inferred_persona,role_level,industryandcompany_segmentfield descriptions in the search catalog, which agents and the NL-to-SQL generator read to decide how to query.inferred_personadescribed itself as inferred from conversation content and better populated thanspeaker_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_levelis 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_segmentnow 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 existingcompany_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 onlycompany_post_closeleaks the other three while looking correct. - New workspaces start in onboarding. A newly-created workspace is now born with the
self_serve_onboardingflag 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/membersacceptscreate_if_missing: true, which provisions the account for an unknown email (and emails them) rather than refusing withuser_not_found.
Fixed
- The
evalsMCP tool now documents every inputprompt-and-message-evalaccepts.reference_accounts(the companies your copy cites as proof) andartifact_typewere always forwarded but never advertised, so a caller reading the tool surface could not discover them. - Eval runs no longer fail with
report_unavailablewhen 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_accountsalongsideaccountif your copy cites several companies as proof. Previously it could only ever sayaccount, 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_accountshint 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 like11xanda16zwere 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=trueto 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 atdata.run.statusand the report read nested it again — so a hand-rolled poller written against the submit shape matchedundefinedforever instead of erroring. Both reads now mirrorstatusat the ROOT alongside the existing nested field, which is unchanged, so this is purely additive.GET /eval-runs/{id}/reportalso publishes a real response schema instead of an empty{}. The terminal success value iscomplete— nevercompleted. - The eval `mode` default is documented correctly again: it is `rewrite`, not `advisory`, and `gate` is a real third mode. The default moved to
rewritein 2.22.0 but three published surfaces still saidadvisory— theevals.runoperation description, the MCPevalstool'srunaction, 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, andgate(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.runfast lane was retired — its warehouse behaviour issearch.querymodefuzzyverbatim — but the connected-client instructions still routed quick lookups tosearch { action: run }, described amode: blendedweb+news leg, and undercounted the tool set as four. A connected model following that text hit aninvalid_argumentat the moment it tried to act. The routing table now points atsearch { action: query }, the section documents the real lanes plus theasync/job_id/retry_guidanceloop, andevalsis 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-Idheader that has never been set anywhere in the server. The id that actually joins your request to our audit trail is the inboundX-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
100on the filter and semantic lanes but50on 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_idpolling), themax_subqueriesbreadth knob (5 synchronous, 12 async), andretry_guidanceare now documented too; all three shipped undocumented. - The BigQuery theme-index fallback's fields are documented correctly.
search.mdxnamedcluster_idandscore, which do not exist on that path; the real fields aresource_id,similarity,member_countandinterestingness_score. An integration written against the old text gotundefinedon 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) andstructure(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.companyacceptsdomain,name, or both —domainwas 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_typenamed 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 atomicutterancessource 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 wholecontentas one person is wrong there even when attribution is clean. When it fires,turn_speakerslists 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_scoreandinferred_personaare derived from the turn's speaker (thedeal_stakeholdersjoin is keyed onspeaker_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
interactionssurface directly —data.query, thesearchfilter 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_countis normally lower thanmatch_count, and we told you that difference wasunresolved_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 withunresolved_matchesat 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_matchesnow reconciles. - `unresolved_matches` and `collapsed_matches` are emitted whenever `hydrate` ran, including when they are 0.
unresolved_matcheswas 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
audiencepredicate, an excluded interaction, or yourlimitbinding.truncatedandaudience_appliedtell 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
contentpluscompany_name,speaker_titleand the rest of the label columns.corpus.row_countis therefore at mostcorpus.match_count, so comparing them is now meaningful. - `corpus.hydrated_grain` tells you which bridge ran.
turnis the row-per-match answer.callmeans the turn lookup itself failed and the rows are arbitrary turns from the right conversations — flaggeddegraded: 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
limitread as complete. source_row_idstill does not join the filter lane, and that has not changed: it is an atomic utterance id, one level below the grouped turninteractionsis keyed on. The turn id that would join is whathydrate: trueresolves 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 landsreview_status: 'skipped'with the reason onrejection_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-Afterheader with the exact seconds until the window rolls, therate_limitederror code, anddetails.retry_after_seconds/limit/window_seconds. Previously it surfaced as a flatoperation_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 /mereports — pacing against 60/min was spending a budget of ten. Every response now advertises the real one inX-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 badintent, an over-longtext) remain400. - 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-secondsX-RateLimit-Resetformat 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
SOC2andSOC 2as different words, so a quoted query for one was invisible to every row written the other way — and came back asempty_reason: "no_match", the one empty value that means "nobody said this" rather than "something went wrong". Thelexicalmode 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 undercorpus.spelling_variants: which spellings it searched, how many rows each found, and how many survivors yourlimitcut. When results come from more than one spelling they are ranked by rank fusion, because the per-rowscoreof one spelling is not comparable with the other's. If a spelling could not be searched at all, the reply saysempty_reason: "spelling_variants_unsearched"instead ofno_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” concernssearched 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_SEARCHtunes 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_typenow published (10 values;build_vs_buyandincumbentwere unreachable before),speaker_typegainsunknown(131,505 rows),record_typegainstaskandevent,interaction_purposegainsinternalandunknown,interaction_typegainsEVENTandTASK,deal_stage_categorygainsN/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
semanticlane 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 (
searchsemantic 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
semanticlane 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
semanticlane 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.storeon asearch.queryresponse tells you which index answered. - Deal health no longer scores the wrong person. A row on the
interactionssurface is a grouped turn — several utterances folded into one row and stamped with its first member's name — and/deal-healthused toGROUP BYthat 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_countcounts 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 — andsentiment_countscounts only turns that resolve to that person alone, becausesentiment_primarylabels 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-stakeholderattributionblock (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 asunverified_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_threadnow fires on it and carriesverified_speaker_countalongsidespeaker_count.executive_entryrequires a verified identity — an unchecked stamp raises a plainnew_stakeholdercarryingspeaker_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 returnsspeaker_attributionon each mention and omits the name on an ambiguous turn;mention_countis 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_titlenames 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 fixedsearch.querywithhydrate: trueand said plainly that reading the surface directly still returned the stamp. It no longer does:data.query, thesearchfilter 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 atomicutterancestable 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_speakerslists everyone in play, and nobody is named."turn"means the value is still the unverified stamp.speaker_ambiguousremains 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_scoreandinferred_personaare 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 labelledspeaker_attribution: "turn"and an envelope block,speaker_grain, carryingattributed: falseplus 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_typechanges, so every member of a turn shares one — a query selecting onlyspeaker_typegets no label and pays no extra read. - Not covered by this change: paths that read the warehouse outside the SQL gate — the
/deal-healthstakeholder 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_loopscores 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 returnedoverall_score: 1.0for 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 asverdict.headline.submittedandcompare.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'spass/partial/failbucket still follows the IMPROVED side, so apassbeside 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.nullon a refusal is unchanged (never0, withnot_applicable_reasonbeside it), and an eval with noimprovement_loopgrader 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-evalmoved to2.14.0with 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_deltadifferencesoverall_score, so a comparison spanning 2.14.0 differences two different quantities; readsubmitted_score_deltathere, which is derived from the improvement report and is unaffected.GET /evals/<slug>/kpialready 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_kinddistinguishes console / api / mcp / agent (was alwaysmanual), and run reads carry an additivetriggered_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.requestsis a list of{ op, params }— the actual reads the run issued (data.cluster_searchwith its query and limit,data.querywith 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
accountcannot reach it, which is why suggested research steps kept coming back with an unfilled{{account_name}}in them. The verdict now carriesrequires_input: the field that was missing, why it matters, and — when a company named in your draft resolves against your owncompany_namerows — 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 anaccountyou send changes what gets retrieved.suggestedis 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
regradeblock onGET /eval-runs/{id}andGET /eval-runs/{id}/gate— the pasteableevals.runparams (evidence_from_runplus 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 atGET /eval-runs/{id}/evidence(MCP: theevidenceaction on theevalstool): 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_provenancehas always reported both counts — so a smallcitedagainst a largepoolwas 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). Readreached_judgeas THREE states:truereached the judge,falsewas 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 tofalse, becausefalsewould 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_loopsends a 20-quote prefix,evidence_judgesends its whole retrieval. - Evals: a run whose only evidence grader is `evidence_judge` now records a pool at all. Previously only
improvement_looppersisted one, soGET /eval-runs/{id}/evidenceansweredevidence: nullfor those runs andevidence_from_runhad nothing to pin. Both graders now record. On an eval declaring both, theimprovement_looppool 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/dealStageAtTimebecomespeaker_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 carryidand carry nostance(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|judgereshapes each line into a training record instead of the stored verdict:pairsis preference tuples (your draft asrejected, the improved version aschosen) for DPO/ORPO,sftis instruction to completion with the improved copy as the target, andjudgeis 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), andGET /eval-runs/{id}/gate(also the MCPevalstool'sgateaction, with the samewait_mslong-poll) returns the machine verdict on every run:gate.passed,checks_passed/checks_total,score_15vsthreshold,basis,simulated, anddimensions_failed. Wire THIS into a send/hold or CI gate - neververdict,overall_scoreorlift, which follow the rewrite the eval wrote and pass ~92% of runs.gateisnullwhile running and on a refusal (not_applicable_reasonsays why); a refusal is not a fail.prompt-and-message-evalbumps 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) andnames_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 andoverall_scoreall 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 thatclaims_annotated: 0meant 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 counters0(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,
/evidenceis the quote pool,/gateis 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 thedraftsaction on the MCPevalstool. Three fields tell you what you are holding:sourcenames which stored copy answered,annotatedsays whether<unverified>markers were stripped from it, andabsent_reasonsays why a side is empty — a refused run reportsimproved: nullwith 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_notenaming 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,/gateand/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/exportreturns flat rows for analysis (also theevalsMCP tool and the Anthropic surface);GET /evals/export.csvandGET /evals/export.jsonlstream the same filtered set as a file - CSV a row per run, JSONL the stored verdict verbatim. Filter byeval,eval_versions,from/to,statusesandverdicts. Callcount_only(orGET /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 passallow_truncationto take the most recent 5,000 deliberately), and every number that needs a qualifier ships beside it -overall_scorewithapplicable(a blank cell is never a zero),liftwithlift_reportable, the submitted score withinput_simulated. You export your own runs; a workspace admin exports the workspace.redact_quotesblanks the verbatim customer quotes on the JSONL download; CSV never carries quote text. Scopeevals: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,facetshad length 2 with both counters populated. The advice was the bug. The new read returns the whole report — every facet, pluslift,transition,suggestions,prompt_patch,research_steps,confidence,coverageand thebefore/aftermirror — and omits exactly two fields, which it names on every response in anomitted[]saying what each was and where it still lives:grader_meta(blinding and evidence-provenance disclosure) andstage_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 resourceeval_run://<id>/improvementand as a newimprovementaction on theevalstool — 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 emptyfacetsarray, 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 atcases[].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 inconfidence.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, andoverall_scoreis the submitted side alone, so a cut loop cannot move a number in either direction. Treat an absentstop_reasonas unknown rather than as converged — the wholetransitionblock is omitted when the unpaired absolute grade is unavailable. The docs also now state where theimprovementblock lives and why it can look empty:facetsis never returned empty, so an empty read means the whole parent was absent, which happens inmode: "gate"and on every refusal (empty_corpus,not_outreach,generate_failed, …) — readnot_applicable_reasonbeside it. The narrower/drafts,/report,/gateand/evidencereads carry noimprovementblock 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(scopeconfig:read). What it does NOT do is guess: metadata we cannot resolve comes backnullrather than blank-looking defaults, an id the warehouse has no row for still appears in the list (it is still excluding data),totalcounts the exclusions on the rule rather than the rows that happened to hydrate, and a warehouse outage setsmetadata_unavailable_reasoninstead of failing — so "this conversation has no title" stays distinguishable from "we could not look anything up". An exclusion whose author cannot be established reportsexcluded_by: nullin preference to a name that might be wrong.
Changed
- The eval report card renders plain ASCII hyphens.
GET /eval-runs/{id}/reportreturns markdown built to be pasted verbatim, and it shipped an em dash in its## Eval reportheading 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 theheadlineobject 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-freePOST /evals/validatedry-run) now return avalidation_failednaming 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_inputshipped 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, saysaccountis 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_typenow 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 declaredartifact_typealso 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) alongsideretry_after_seconds, andRetry-Afteris the real remaining time rather than a flat 60 regardless of how much of the minute had passed.GET /evals/export/countreports the livecooldowntoo, 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.csvandGET /evals/export.jsonlnow 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/exportkeeps the cap, because it holds every row in one JSON response body; its refusal now names all three remedies (narrow the range, setlimit, or take the file). They also compress - sendAccept-Encoding: gzip(every browser does;curlneeds--compressed) and the body arrives gzipped underContent-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_runsreceives 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_truncationand 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) plusungraded, the runs carrying no verdict at all. Those runs have an emptyoverall_scorecell (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.ungradedis deliberately kept out ofbuckets: absence of a verdict is a different fact from a verdict ofnot_applicable. - Fixed: the export count could under-report its own version spread.
versionsand the date range were derived from a single read that also carried acount, 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
SELECTnamed acompleted_atcolumn thateval_runsdoes not have (it isfinished_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.csvand.jsonldownload and everyPOST /evals/exportsince 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.
verdictsacceptsungradedalongside the four real buckets - the runs carrying no verdict at all, i.e. the ones whoseoverall_scorecell is empty. It compiles to anIS NULLpredicate rather than passing through as a bucket value (no run storesverdict: "ungraded"), so the numberverdict_counts.ungradedreports is now a set you can isolate or exclude rather than one you can only read. - Fixed: the version list sorted lexically.
versionsused a plain sort, so2.11.0came before2.2.0and 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_scoredhas always been the number of runs behindsubmitted_mean_15, andimproved_mean_15is 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 carryn_scored_improvedbeside it. The improved-side window mean also abstains below 3 runs against its own count rather than against the submitted count, so it can readnullin 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"andsubmitted_mean_15: nullbeside a non-nullimproved_mean_15.abstain_reasonanswers for the submitted side, which is the KPI. Whenever it is present, readsubmitted_mean_15as withheld regardless of what the improved mean reads. - The eval report and the
evalsrun 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
regradehint: draft from those quotes and re-run withevidence_from_runset 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 apollblock (thestatusaction, the run id, and thewait_mscap), 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, becauseadvisoryinstructs the writer to leave the improved prompt empty.advisoryis 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 withholdingtransition.improved_verdict: "pass"whatever the rubric scored, and the offending spans quoted intransition.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, soscore_15, the dimensions,liftandoverall_scoreare unchanged — only the pass claim. The newplaceholders_unresolvedcounter ships on the improved message facet (and only there: a reusable prompt is supposed to carry slots).prompt-and-message-evalbumps to 2.21.0; do not poolimproved_verdictortransitiontallies across that line, nor prompt-facet scores for runs that omittedmode.
Fixed
- Evals: a rubric dimension can no longer be marked not-applicable and scored PASS at the same time. A graded facet's
not_applicableflag 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. Apass: truenow 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 oroverall_scoremoves. Everyimprovement.facets[].before/.afterblock now also carriescontradicted_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 onquery). 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-evalmoves toeval_version2.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,liftand 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.mdguidance (also published verbatim in the publicamdahl-cookbookrepo) 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, notliftortransition) 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-evalmoves toeval_version2.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
statusat the top ofdata; 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 OpenAPI200forGET /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 readingdata.statusthere matchesundefined, which is not an error, so it loops until something else kills it. The terminal value is alsocomplete, nevercompleted— 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 tostatuson each of the five reads that expose it (they differ — only the detail read nests), thecompletespelling is called out, andpoll.wait_ms_maxis 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 theirdataenvelope (/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_reasonis seeded withmax_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 newround_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_reasonis a partition key — comparing two runs withholds the delta onstop_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 samegenerate_failedan 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 ownloop_deadlinewith a sentence that says so. The machine-readablenot_applicable_reasonis unchanged (report_unavailable), so there is no new value to handle — the distinguishing detail ridesevidence.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-evalmoves toeval_version2.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/.afterblock now carriestruncated_not_applicableanddiscarded_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 oroverall_scoreis computed from them, and the caps themselves are unchanged. - Evals: re-run the judge over a fixed improved version.
evals.runacceptscandidate_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 asevidence_from_runor omit it; a _different_ id is refused. A run that submits different copy, or a differentmode, 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 acandidate_pinblock (source run, when the text was frozen, the mode it was written under) and the report carriesgrader_meta.candidate_scopebeside the existingevidence_scope. Passreuse: "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(resourceeval://<slug>/kpi, and the MCPevalstool's newkpiaction, 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: aseriesof day / week / month buckets,current+priorwindow stats, anddelta_15for the movement. The trend tracks the SUBMITTED-side score (verdict.headline.submitted), never the blendedoverall_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 returnsnullmeans plus a namedabstain_reason(no_runs/thin_window) instead of asserting a mean over noise, refusals raiserefusal_ratebut never drag a mean toward zero, empty buckets are gaps rather than zero points, and a capped fetch saystruncated. Scopeevals:read; the console renders the same numbers as a band above the compose panel.
Changed
- When an eval run names an
accountit 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_reasonused to collapse both into oneno_conversations, so the only actionable part of the answer was missing:not_in_corpusmeans no company matched the name you typed — names are matched as written, so a brand name your CRM records differently (LlamaIndexwhere the record saysRunllama) lands here with a full conversation history sitting under the canonical spelling — whileno_quotable_utterancesmeans 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 newaccount_abstain_detailcarries 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 liveaccount_resolvedprogress step, so it is readable while the run is still going.no_conversationsis no longer emitted, so add the two new values if you switch on this field. The eval version moves to2.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.headlineand the report card'sheadlinenow carryquotes_cited_submittedandquotes_cited_improvedbeside an unchangedquotes_cited, the card's footer reads "3 customer quotes cited (0 grading your draft, 3 grading the rewrite)", andoverall_reasoningcloses 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.headlinereference published a FLAT shape (submitted_score_15/submitted_checks/improved_score_15/case_id) thirty lines after prose telling you to readsubmitted.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, includingsentence,what_changedandfacet, 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_versionmoves2.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,lookalikeandevals; 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 byGET /api/mcp/api-keys/toolsnow 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
1or5and the headline is1 + 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 at2and4and headlines of1.6,2.4,4.4and4.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.8means exactly one line passed. Readchecks_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 everygithub.com/amdahlco/*link a published page carries — the full path, not just the repo root — so this class cannot ship again. ground-and-draft'sground.shnow 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 asnake_casehandle 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_versionmoves2.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_versionmoves2.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
segmentand 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_versionmoves2.11.0->2.12.0. - Chat: the
resume_urlhandle returned byPOST /chatnow points at the publicagents.resumeroute, 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.queryfuzzy lane):detail.groups[0].internalis now the result object on REST, matching MCP and the docs (it was serialized as the string"[Circular]"). - Search:
escalate_reasonis now populated wheneverescalate_to_chatis true, naming the sub-question the fast lane could not answer. - MCP prompts:
system/guide_me,system/getting_started, andsystem/research_playbookare now readable viaprompts/getfor every workspace key (they were listed but returned a scope error). - Evals:
GET /eval-runsnow acceptsrun_statusas an alias forstatus, so the MCP and REST filter names are interchangeable.
Added
- Read an eval result over MCP. The
evalstool gainedstatus,list_runs,listandget. 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.statustakes the run id fromrunand returns the verdict. - Wait for a grade instead of polling for it.
GET /eval-runs/:idand theevalstool'sstatusaction acceptwait_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(modefuzzy) now reports its own coverage. The response carriesuncovered— one entry per part of your question that produced no answer, each with the reason (breadth_capwhen the question had more independent parts than the lane answers in one call,deadlinewhen a part ran out of time,planner_incompletewhen the question could not be split in time) — and the plain-languagemessagenames those parts so you know what to ask again. Previously an answer covering three of five parts was indistinguishable from one covering everything.search.queryresponses carry atimingbreakdown (plan_ms,fan_out_ms,coverage_ms,total_ms, plus per-sub-question timings on the fuzzy lane'sdetail), so a slow call can be attributed without guesswork.search.querycan run as a background job. Passasync: trueand you get ajob_idback immediately instead of waiting; call the verb again with just thatjob_idto 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.querytakesmax_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.queryreply now carriesretry_guidance: when part of your question went unanswered it names the exact parameters to re-send (raise_max_subqueries,run_async, ornarrow_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>"toevals.runand 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_metacarries a newevidence_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 oldevidence_frozen: trueonly 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 carriesevidence_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) carryevidence_pinned_from: the id of the run whose quotes they were graded against, ornullwhen 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, andflipped_to_failwhen 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_changednow 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_scoreblends 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 documentsinput_passed/improved_passed/transitioninstead of describingpassedas "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 carryspoken_at, the speaker'srole_level, anddeal_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, sospeaker_sideis 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.runresponses carry aconsole_urlpointing at that run in your workspace console. Previously the reply heldrun_idandeval-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.headlinestates 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 wasoverall_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 returnedoverall_score: 1.0for a draft clearing 2 of 5 checks was reporting both correctly. Your grade was there all along, atcases[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_scoreis unchanged. - The run list carries your grade too.
verdict_summaryonGET /eval-runsaddsinput_score_15/input_scoreandimproved_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 asinput_score_15/input_score. - Comparing two runs now reports how far YOUR copy moved.
GET /eval-runs/{id}/compare/{other_id}addssubmitted_score_deltaandsubmitted_score_15_deltabeside the existingscore_delta, which differencesoverall_scoreand 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 asscore_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 reportsdelta_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: truemarks it on the case, the headline and the run list — previouslyinput_passed: falsethere read as a verdict on your writing. - Reports carry the confidence caveat, not just the level.
improvement.confidence.caveatis the one-line reader-facing note for the level, present whenever the level is belowhigh. - Eval reports now flag rubric dimensions that the kind of writing you sent could never satisfy, and accept an optional
artifact_typesaying 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 carriesnot_applicable, a reason, andnot_applicable_source(declared_typefor a deterministic consequence of what you declared,judgefor the grader's own reading). The flag does not move any score yet —pass,score_15,passed/total, the transition andoverall_scoreare 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; aninferredreading 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}/reportreturns an eval run already written up — a markdown card to paste, plus the same numbers machine-readable onheadline. 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 carryingoverall_score: 1.0describes 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-draftandgrade-and-report— now ship with Amdahl inskills/, 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_deltareported −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.runwithreuse: "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.runwithreuse: "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_provenancegainsin_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) androunds(how many retrieval rounds it took - consistently more than one is a signal your corpus is thin for the questions being asked).poolkeeps 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 arechecks_passed/checks_totalon a facet (the olderpassed/totalremain, carrying identical values, and are deprecated), and a dimension answers topassedas well aspass. 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.
comparenow reportsdelta_withheld_reason(evidence_not_controlled·stop_reason_mismatch·not_scored·inside_noise_floor) andnoise_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 anditerationsstill reports them, they just no longer move the number. `grader_meta.blinding.sampling` reports what each stage actually sampled at — the configuredtemperature: 0does 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(modefuzzy) no longer come back as a bare failure. A question with several independent parts could exceed the lane's internal budget and returnfailedwithescalate_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(modefuzzy) 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.
uncoveredcarries aplanner_incompleteentry 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_applicableinstead 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.querynow 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
searchtool 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 sharedllmCompleteseam. 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 nestedunionErrorsarray of per-branch failures under a top-level message of just "Invalid input" — from which you had to work out thatmessagewanted a string. A malformedinputscame 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, ondetails.input_errors; anything the request shape itself rejects renders as a path-addressed line ondetails.errors. No response carries validator internals. Separately, anumberfield 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 aseval-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://, andagent_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://, nownotification-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
usageand that a failing draft is the finding, but never thatoverall_scoreaverages graders judging TWO different artifacts — the hygiene rules score the text you submitted,improvement_loopscores the improved version the eval wrote — and that the case verdict follows the improved side. An assistant reading apasscould tell someone their draft was fine when the report said it was unusable. It is now told to quoteimprovement.transition.input_verdictfor the user's own writing and neveroverall_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
accountandmodeinputs 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_scorenow comes backnullwith a top-levelnot_applicable_reasonnaming 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 carriesinput_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 hard0besideinput_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
audienceinput onprompt-and-message-evalnow 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 — taggedsegment, 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, andcorpus_onlyride 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
datablock withserver_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 requestidinstead of always returningnull. - 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:
modewas documented as defaulting torewritewhen the default isadvisory; 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 is2.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'spassedas "whether every applicable grader passed" when it follows the improved side, so a run could readpasswhile your own draft was unusable; it also said retrieved quotes are never the recipient's, which stops being true the moment you pass anaccountthat is in your data; the run handle'sresourcewas spelledeval_run://when the server returnseval-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 withinvalid_argument, because no built-in eval ships ingeneratedmode; and the eval builder's rules still named the retiredsearch.runinstead ofsearch.query. The evidence-tier tables now also say plainly thatsegmentis 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/querywith"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 onresults, the SQL it ran is oncompiled.sql, and the full warehouse envelope you used to read at the top level (internal,groups,message,coverage,escalate_to_chat) is ondetail. Scope is unchanged (data:read). On MCP thesearchtool'srunaction is gone the same way; use itsqueryaction. - The blended web + news pass is gone.
mode: "blended",external_limit, and theexternal/external_omittedresponse fields no longer exist. Amdahl's synchronous search reads your own warehouse only. For market signal, useexternal_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: trueand thesynthesisresponse field are removed. The search verbs return data, not prose — ask Chat when you want it written up. - Authored evals using a
generatedsubject must settarget.optosearch.queryinstead ofsearch.run, and thehas_citationsdeterministic 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/totalbesidescore_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 newconfidence(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.blindingcarries 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_provenancelabels 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-evalaccepts a newaccountinput. When that company is in your conversation data, their own buyer-side words are retrieved and taggedaccount— 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 atier(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. Thebasic-hygieneupper 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 ownlengthcheck.
Removed
POST /search(search.run) and therunaction on the MCPsearchtool.- 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 blendedsearchor anenrichbrief 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 carriesexternal.source_failures[]({ source, reason }, wherereasoniserrorfor an outage vssource_timeout/deadlinefor slowness).sources_timed_outis 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_primaryandbuyer_pushbacknow 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 toobjectionon 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_filteror thecomms_filterparam onconnections.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 newgrouped_utterance_idcolumn exposes each utterance/turn's own id — the same value previously reachable only under the confusingly call-scopedinteraction_idname. Pairs with the existingsequence(turn order) andparent_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 useparent_interaction_idwhen you need an id that survives regrouping. buyer_pushback/pushback_typeon theinteractionsdata surface. Two new columns are now queryable viadata.query,data.ask, and MCP schema discovery, answering "did the buyer push back on adopting our solution" directly instead of the coarserobjectionsentiment label (which also fires when a speaker is describing their own business, not ours).buyer_pushbackis a tri-state flag —NULLmeans not yet judged,FALSEmeans judged and not pushback,TRUEmeans judged pushback — andpushback_typenames the kind (pricing, timing, proof burden, and others) whenTRUE. 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-gradereval 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) orgenerated(the eval runs a question and grades the answer - thegtm-defaultregression harness).POST /evals/runover REST or theevalstool'srunaction over MCP validates your inputs against the eval's schema, starts the grading job, and returns a run id immediately; polleval_run://<id>for the verdict - an overall score, apass/partial/fail/not_applicablebucket, and a per-case, per-grader breakdown carrying the before→afterimprovementreport. A workspace with no customer data yet comes backnot_applicable, never a false fail. Six grader kinds ship (rule+improvement_loopover a message,evidence_judgefor a grade-only pass,deterministic/sor_anchored/judgeover a generated answer); browse them viagrader_kind://list. Author your own eval - configure the inputs, cases, and graders - withevals.create/update/delete, and dry-run a draft withevals.validate. Runs are read-only (they grade your data, never change it) and grade current data (internal-only in v1). Scopes:evals:executeto run +evals:writeto author (both editor + the customer-agent key bundle),evals:readto browse, validate, and poll (viewer). See the Evals guide.
Changed
- A long prompt submitted to
prompt-and-message-evalis 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 reportsmap_reducewith nothing omitted. - Prompt and Message Eval:
mode=advisory(anchored suggestions against what you already have) is now the default; sendmode=rewritefor a full improved prompt and message. Themodeinput 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_reasoningnow 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_summarynow carries the primary case'stransition/input_passed/improved_passedwhen 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 toX-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_stepswere 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 a403for 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 ausagefield: an improved message isillustration_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_reasonis one ofthreshold_met,no_critique,deadline,model_call_cap, ormax_iterations.iterations: 1was 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 nowmessage(wasoutbound_message). Both retired slugs (outreach-eval,message-grader) still resolve and the retiredoutbound_messageinput key is still accepted, so existing integrations keep working. Thegtm-defaultregression-harness eval has been retired; thegeneratedsubject 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.runnow meters and caps like the other synchronous verb families: every dispatch records a usage row, and an operator-setmax_evals_calls_monthhard cap returns the standardquota_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_searchand 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-serverand/.well-known/oauth-protected-resourcedocuments previously returned a hardcoded origin, so a client reaching the API on a newer Amdahl domain was handed anissuer,authorization_endpoint,token_endpoint, andregistration_endpointpointing 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.codeep link and the seat-pricing constants named the old pricing page, both left over from the move toamdahl.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 withredirect_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-runsnow rejects an unrecognisedstatusfilter with400instead of silently returning every run unfiltered, and echoes the appliedfilterson the response. A malformedlimit/offsetis rejected too; an out-of-range one is still clamped to the allowed bound.POST /evals/runnow 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 fromempty_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://andgrader-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.aiagain, 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
dataschema guidance no longer suggests filteringsentiment_primarytoobjection(or the other negative labels) as a way to find or rule out objections and risk. Measured against production data,sentiment_primaryis 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 atcluster_searchand 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_segmentoruse_crm_segmentkey 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, HubSpotamountand Pipedrivevalueare 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,quarterlyorannual), along with its currency. Deal and interaction queries carry that declaration beside the value asdeal_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_amountstill 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 readsunspecified, 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_widgetoperation (GET /knowledge-base/:id/node/:node_key/widget) returns one living-doc node's figure as a standalone single-visualizationPageSpec, 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) andexclude_domains(whole domains, suffix-aware) onconnections.set_call_filtersand the atomicconnections.connectcall_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` object —
latest_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 resultmessagesays 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-metadatareturns 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-servernow includesservice_documentationand anagent_authblock (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 athttps://amdahl.ai/auth.mdand the agent-skills index athttps://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 (bydocument_group_id+node_key) to their Home, each rendered standalone via the node-widget read. Personal to the caller (gated by the newpins:read/pins:write/pins:deletescopes); console + REST + Anthropic only. - Structured search modes on the API:
POST /search/queryroutes each ask onto typed filters, plain-language search, or meaning-based (semantic) matching over your conversation data — force a lane withmode, or let Amdahl pick. Discover the filterable fields atGET /search/fields. - Enrichment endpoints:
POST /enrich/company,POST /enrich/person, andPOST /enrich/topicreturn 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 withmode=full. - Lookalikes:
POST /lookalikefinds the companies or deals most similar to a seed account, andPOST /lookalike/themesfinds 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 optionalcall_filtersfor 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. Omitcall_filtersand 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_searchgains an optionalfacet_filterso 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_unityou 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_amountstill 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_unityou 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, thesearchMCP 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 newgroups[]field (the flatinternalfield 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 anescalate_reasonpointing 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 thesearchandagentscoarse 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 return403 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_idon 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_normalizedvalues they produced) are gone. They bucketed a deal amount whose unit was never declared, so they were wrong for any workspace tracking deals monthly. Usefirmographic_segmentfor company size, and readdeal_amountwith its newdeal_amount_unitfor 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_nameis 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 acompany_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_overviewandsummarize_researchthe 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 noquery, 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/listreported itshealthashealthyeven though no credential existed and no data was flowing. Two changes: a connection with an unfinished authorization now reportshealth: "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 tostatus: "disconnected", which is the stateconnections.reconnectrestores 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 theirsample_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.
enrichon a company also accepts anamewhen you have nodomain, resolving it to the account in your workspace instead of erroring. - `lookalike find` works by domain. Passing a company
domainnow 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 distinctdomain_unresolved/entity_not_found/centroids_not_materialized/errorcases so you can tell what actually happened. `lookalike themes` now honourslimit, 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/querywithmode: "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 nullinteraction_idand 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_provenanceonknowledge_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
depthexplicitly previously usedstandard; it now usesdeep— 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, passdepth: "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
dealsdata 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:citelink grammar — a markdown link whose destination isamdahl:cite?block=<id>(blockrequired — the id of a presented evidence block;fuoptional — 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 flattenedanswer_textfor MCP consumers / logs strips the scheme entirely so prose reads clean. Orthogonal to the existingamdahl:qfigure-exploration links — the two never clobber each other. - A historic living-doc version can now show its provenance.
knowledge_base://<id>/suggestions?resolved=1(RESTGET /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 itsstatus,resolved_at,resolved_by_user_id, andresolution_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 aproposedversion for a human to promote, keeping the approve/decline review meaningful on docs that otherwise auto-supersede pending suggestions. Read the current mode withGET /knowledge-base/:id/review-mode(resourceknowledge_base://<id>/review-mode) →{ document_group_id, review_before_publish }; set it withPOST /knowledge-base/:id/review-modebody{ 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_completeagent event (live SSE, persistedstep_data.events, and the Chat run READ?include=eventsreplay) gains an optionalresultSample—columns, up to 25rowskeyed by column, and atruncatedflag 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_detailread, not a warehouse row, so it previously had no openable evidence to link to. Answers can now present a theme as acluster_findingblock (its title, insight, narrative hook, and member count, plus representative rows) and bind the figure to it withamdahl: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
citationblock (kindutterance— an attributed, openable evidence card) and binds the claim to it inline withamdahl: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 — atable/chart_speccarrying 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_searchstep in the Chat transparency panel shows a sample of the themes it returned (label, score, representative quote), matching the row preview already shown fordata.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>/suggestionsfilters 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_typeschema guidance the NL→SQL assistant reads (viadata://schema) stated that email interactions exist only for HubSpot workspaces and that a Salesforce-CRM workspace has zerorecord_type='email'rows. That was incorrect — Salesforce logs emails as Task/Event activities that the pipeline normalizes torecord_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 livesample_valuesbefore 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_daysparameter (operation + MCPdatatool) injects the correct event-time window for you — passlast_n_days: 90instead of authoringTIMESTAMP_SUB/ interval syntax. Thedata://schemaresource now carries asql_dialectblock documenting the warehouse's SQL rules, and failed queries return targeted rewrite hints (e.g. Postgres-styleINTERVAL '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) includesanswer.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 intochat.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-queriesserves 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_specblocks acceptfunnel,radar,treemap, andgaugealongside 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.delegatenow requires the newagents:delegatescope (split fromworkflows:delegate, which still gatesagents.start/agents.fork_blueprint). Master no longer resolves the specialist-start tools; lasting saves go throughoutputs.write_doc. Discover valid living-doc slugs viaGET /outputs/output://list. - Blended fast search now enriches the web query with your workspace context. In
POST /searchwithmode: "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 newexternal_omitted: "not_relevant"instead of returning off-topic links (this never blocks a healthy fan-out — uncertainty runs the search). The result'sexternal.external_query_usedshows the query the web pass actually ran. Also in this release:agents.startwithasync: falseno longer reports a slow specialist as an error — if the run is still working after the ~60s bounded wait, the call returns success withstatus: "running"andstill_running: true(the async contract), and a run that genuinely failed mid-wait surfaces the honestrun_failedcode; anddata.queryresults 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>, theagentstool'schat_status) now reportsusageas tokens +turns_usedonly —estimated_cost_centsis gone, as iscost_cap_centson chat summaries and theconversation:///agent_run://reads. Themax_cost_centsconfig knob is removed fromchat.start, chat preferences, and routine configs (sending it now rejects as an unknown field); every new chat gets the platform-managed limit automatically. Thecost_cap_exceededpause is renamedusage_limit_exceededwith 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.75stakeholders), 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 /agentsAPI family. Amdahl-shipped agents (starting withresearcher) appear alongside your own; platform agents are read-only. - Agents can now take outbound actions — with your permission: the new
POST /actions/invokeAPI (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) ornotion_sync(push a knowledge-base document into your connected Notion now). Actions are opt-in per run via anactions_allowedlist that defaults to empty, so an agent that was not explicitly granted an action cannot send anything — it proposes instead.GET /actions(theaction://listresource) 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/writeAPI (and the matching agent tool) commits durable facts to the workspace context store. Every agent-written entry is automatically taggedauto_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_humanagent 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) orfree_form. Only the asking run waits: a sub-agent's question is escalated to its caller's event stream as achild_needs_humanframe 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 withon_question: "auto"skip the ask and proceed on stated assumptions; headless / scheduled runs (on_question: "none") get an immediateno_human_in_looperror instead of a hang. - See exactly which tools a workspace agent can block: the operation catalog read takes a new
blockable_for=masterfilter (operation://list?blockable_for=master, RESTGET /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 theagentsMCP tool's chat reads) now carries atrigger(chat·agent·routine), theroutine({ id, name }) behind a scheduled fire, and theagent_refa 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/delegateAPI (and the matching agent tool) dispatches a sub-agent — a roster agent likeresearcher, 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. Passtask, plus optionaldesired_outputandcontext, 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.agentspicks 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 byagents.delegatewithagent_not_allowed). Every roster member re-resolves at fire time. The legacy singleagentpin still works; passing both rejects. Theoutput_slug/full_regendoc-target knobs left the writable config — the agent picks its own document slug viaoutputs.write_doc. - Workspace agent controls:
slugis now optional onPOST /agents(derived from the name and uniquified when omitted), and every workspace agent accepts atool_blocklist— operation ids subtracted from the agent's kit at run time (all kit tools are on by default; runner-inline tools likefinishcannot be blocked). - Fast search — data back in one call.
POST /searchis 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. Passmode: "blended"to also fan out a quick web + news citation pass (internalis the default, warehouse only),limit/external_limitto size the result, andsynthesize: truefor 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 thesearchtool (one action,run). Needs onlydata:read; the blended leg usesexternal_search:executeand degrades to internal-only without it. See the new Search guide. - The
searchMCP tool. Connected MCP agents (Claude, Cursor, ChatGPT) can now ask Amdahl anything end-to-end:startopens or continues a named Session and runs one Master agent turn server-side, returning handles immediately;statuspolls the run (with an optionalwait_mslong-poll up to 30s);respondanswers a paused human question;cancelstops 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
agentsMCP 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_routinewith a prompt + cron for a standing scheduled ask,update_routine,delete_routine, andrun_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_humanfails fast withno_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, andPOST /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. Runoutputs.run_bound_queriesfirst: 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_textplus an orderedcontent_blocks[]list (text,callout,citation,table,chart_spec,metric). Data-backed blocks include both the declaredqueryand the snapshotdataon 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 newanswer.presenttool; blocks stream ascontent_blockSSE 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 theknowledge_base://<id>/suggestions/<suggestion_id>/commentsresource), reply withPOST …/comments(body, optionalthread_root_id), and resolve/reopen withPOST …/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/runexecutes 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/askanswers a question grounded only in that node's bound queries and their latest captured signatures. Reads needknowledge_base:read; the thread writes needversioning: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 theknowledge_base://resource scheme, and an MCP client runs a node's query by reading its SQL fromknowledge_base://<id>/treeand calling thedatatool 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 /search→POST /chat;GET /search/sessions[...]→GET /chats[...](rename viaPATCH /chats/:id); the start body'ssession_id/session_name→chat_id/name, and responses returnchat_id(list responses returnchats, detail responseschat); op idssearch.*→chat.*(chat.start,chat.list,chat.get,chat.get_run,chat.rename); thesession://MCP resource scheme →chat://. Over MCP the standalonesearchtool is retired — its four lifecycle actions live on theagentstool asstart_chat/chat_status/respond/cancel_chatwith the same check-in contract (START always returns handles; pollchat_statusor readchat://<id>/runs/<run_id>;wait_mslong-polls up to 30s). Routines fire Chats;run-nowresponses returnchat_idand routine reads carrylast_chat_id. See the Chat guide (formerly the Search guide). - Chat `depth` is now a real investigation tier.
quick,standard, anddeepno longer differ in name only — each sets the model, the turn budget, and the toolset for you.quickruns a lean, fast toolset for a focused lookup;standardis the everyday full toolset;deepuses 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.
evidenceis gone — citations and the query behind every data-backed answer block are now attached unconditionally, so there was nothing to toggle.as_ofis 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 (thedata.querytime-travel primitive is unchanged). Sending either field now returns a clearinvalid_argumenterror 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.querynow refuses a silently-truncated result: when a query's population exceeds the effective row limit, the call returns a structuredresult_truncatederror (with guidance to aggregate in SQL or bridge on an id list) instead of an arbitrary slice. Passallow_truncation: truefor a deliberate bounded top-N (ORDER BY ... LIMIT n).
Removed
- The
blueprintsMCP 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 newagentstool's Routines for recurring work and the newsearchtool for one-shot deep work. MCP API keys need no rotation; theblueprints:executescope on existing keys is simply inert on the MCP surface. - The
pagesMCP tool. Connected MCP agents (Claude, Cursor, ChatGPT) no longer author, list, or delete workspace Pages, and thepage://+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; anypages:*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
disconnectedat birth — so the console showed Disconnected instantly and the sync scheduler (which only picks upconnectedsources) never ingested anything. The trigger is retired, andPOST /connections(api_key) plusPOST /connections/:id/reconnectnow order the flow aspending→ store the key →connected, so a connection only ever readsconnectedonce 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_ofto adata.queryover thedeal_funnelsurface 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 backeddeals,deal_qualification,clusters, andpositioning_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_qualificationas-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, andpipeline://qualification-healthreads (MCP resources + RESTGET /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.querycan also read the newdeal_winloss(per-pipeline win rate + sales cycle) anddeal_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_winlossviews, 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, orcontext.review_kit) returns astep_statesentry 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
connectionsreads now group it underactions(filter withcategory=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_searchpreviously 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>; RESTGET /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_qualificationnow exposesrealized_is_won/realized_is_closed(a company's realized deal outcome), andcompetitor_mentionsnow exposescompetitor_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 withas_of. data.querynow accepts Common Table Expressions (WITH … AS (…)) and subqueries (inFROMandWHERE). 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.querynow 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.explorewith 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 firstexploreof a session no longer stalls for tens of seconds).data.cluster_detailnow accepts a durablelineage_id(surfaced on everydata.cluster_searchresult) as an alternative tocluster_id. A theme'scluster_idchanges each time topics are recomputed, so a saved handle could go stale; thelineage_idis 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_blueprintnow resolves a tenant blueprint by itsidentity.slug, not only by its UUID. Running a blueprint you authored by its slug used to returnnot_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-reportworkflow declared alookback_daysinput 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. Thelookback_daysinput is removed: only thedealscut 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_daysinput 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. Thelookback_daysinput 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_daysinput (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. Thelookback_daysinput 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:
LineChartacceptsyMin+yMaxto 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
Tabscomponent (labelled panels the viewer switches between — each tab carries an inline catalog subtree) andCarouselcomponent (rotate sibling charts one slide at a time), plusQuoteList.expandable+QuoteList.detailFieldsfor a per-quote read-in-place detail dialog with richer attribution. - Workspace README — a one-read orientation map of your workspace. Read
workspace_readme://currentover MCP (orGET /workspace/readmeover 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
heightpreset (sm|md|lg) and an orderedcolorstoken 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.RadarChartadditionally acceptsmaxto pin the radial scale (e.g.max: 5for a /5 rubric fills the polygon), andFunnelChartno 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.uploaddefaultsversion_as_ofto 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 newmax_rivalsinput), 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
transformstep could not read a workflow input via the canonical$inputs.Xreference, 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
totalsquery (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.querywhose result exceeds itsLIMIT(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 viaallow_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
searchaction, sosearchcould 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 onsearchas well as the company/person/topic enrichments.
Added
knowledge_base.promoteandknowledge_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 oncompany_id.deal_score_weightsexposes 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 viadata://schemaand honor data-scope —competitor_mentionsis company-scoped like the deal-grain surfaces, anddeal_score_weightsis a business-level admin/GTM read. POST /agent-blueprints/:id/activate— activate or deactivate a workspace workflow (body{ enabled?: boolean }, defaulttrue; 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) andworkflow_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`.
CopyButtoncopies 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.Dialogadds 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, RESTGET /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.refreshand theindustry_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/syncandGET /notion-sync/documents/:document_group_id(plus thenotion_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/:idon 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
dealssnapshot can't.deal_stage_historyis one row per stage change (deal_id, the raw CRMdeal_stageid,changed_at);ref_deal_stagesmaps each raw stage id to its human label, funnel position, and won/lost/open class. Readdata://schemafor 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
searchresult carries an optional_timingbreakdown (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_workflownow carries the producing workflow'snameandblueprint_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_runblueprint 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_topiccall. 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.uploadAPI now also returns aconsole_urldeep 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
CalendarandBoardpage components (authored via thepagestool) gained two optional props for this:contentField(the row column holding a piece's full markdown body) anddocUrlField(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 a400 invalid_category. This unblocks the Living Knowledge "needs review" count. - Living Knowledge workflow activation now reflects execution reality. The
workflow_activefield on documents and thePOST /agent-blueprints/:id/activatetoggle read/writeblueprint_schedules.enabled— the cron scheduler's source of truth — instead of theworkspace_workflowscuration 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_basetool (both the API operation and the MCP coarse tool) now returns aconsole_urlfor 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_topiccall 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, soPOST /agent-blueprints/fork(and theblueprintsfork 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_urlattached to aknowledge_base.uploadresult (on both the operation and the MCP coarse tool) was built from the document'sdocument_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 anas_ofdate now resolves the cluster generation that was live on that date (via the warehouseclusters_as_oftable-function) instead of being refused — so a blueprint that reads themes with raw SQL backtests cleanly. The append-onlytheme_lineagehistory surface still requiresas_ofreads to go throughdata.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 withpages.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_searchandkb_searchsources (in addition tosqlandartifacts), 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) andindustry.list_themes(the clustered market themes) operations, or trigger a fresh sweep on demand withindustry.refresh. - Substack and YouTube are now searched in external research. Founder newsletters, talks, and demos are strong go-to-market signal, so
external_search(thesearchandenrich_topicactions) 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.listentries now includeforked_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
industrytool'srefreshaction kicks off a "What's Going On In The Industry" sweep on demand instead of waiting for the daily run, and returns arun_id. Poll the newindustry_run://list+industry_run://<id>reads to watch the run to completion and read its per-stage step log; readindustry://themes/industry://divergencefor 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_msis also clamped so the full pipeline (search + synthesis) stays under the tool-call ceiling — a largedeadline_msno 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_searchno longer searches them; theenrich_personaction 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
undefinedprefix, 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. Theresources,social,pages,notifications,industry, andblueprintstools now also accept the same optional context so a client populating it gets full coverage across every tool, not justdata/context/knowledge_base/external_search.
Added
- Run and schedule Workflows directly from MCP. The
blueprintscoarse tool gains arunaction (a one-shot headless run) alongsidecreate_schedulefor recurring cron runs, gated by the newblueprints:executescope 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
pagescoarse tool gains acreate_from_templateaction that materializes a vetted, Amdahl-shipped page template bytemplate_slug; browse the available templates via thepage_template://listresource first. - Read drafted content pieces back over MCP. A new
content_piece://read scheme (content_piece://list+content_piece://<id>, gated byartifacts:read) surfaces the content pieces a content-calendar Workflow drafted, so an agent can review a run's output without leaving MCP. agent_blueprint.listnow returnsis_activeandsourceon 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(searchand theenrich_*actions) surfaces social signal instead of coming back empty.
Added
Actionpage component + thepages.invoke_actionoperation, 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_templateoperation. 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
Actionbuttons can now invoke `workflow` operations (e.g.agents.run_blueprint), not justwrite/computeops — 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); areadstays 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 needsworkflows:delegate). - Page
Actionbuttons can also target external providers via anexternal:<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 referenceexternal:echoprovider 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
CalendarandBoardpage components, plus anartifactsquery 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 warehousesqlsource.- A ready-made Content Calendar page template (
content-calendar) that pairs the two views over yourcontent_pieceartifacts. Fetch it viapages.get_templateand 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.querynow addresses two positioning surfaces,positioning_claims(your claim inventory: text, label, category, status) andpositioning(per-claim performance: leading buyer-reception lift and matched-control lagging win-rate lift), discoverable viadata://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_llmclaims are pulled from what your team actually says on calls (their lift is real messaging performance), whileseedclaims 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 thedata.queryschema 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/summaryand theconnection://<id>/summaryMCP 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
dataquery / explore over the stakeholder columns folded onto the interactions surface (is_champion,is_economic_buyer,champion_score,role_level) plus thedeal_qualificationchampion / economic-buyer / competition dimensions — taught in the schema explorer and the GTM routing guidance so the agent reaches for them automatically. - The
datatool's schema catalog now advertises the nativerecord_typecolumn (call/email/meeting) for filtering interactions by category. Category-filter prompts and examples use it instead of the olderinteraction_typeremap, so questions like "emails about pricing" or "calls per company" produce clearer, more accurate SQL.
Removed
- Removed the
include_knowledge_graphoption ondatacluster 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
datatool:cluster_detailnow accepts thesource_idreturned bycluster_searchverbatim. Previously thecluster_idargument only accepted a number, so drilling into a theme failed with "Cluster not found" because theme ids are opaque strings — pass thesource_idstraight 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'sdescriptionis 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 anydescriptionyou pass is ignored — record per-version "what changed" notes inversion_changesinstead.- 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
blueprintstool 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
notificationstool andPOST /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
pagestool family and REST, publish, and embed.
Changed
- The
performance.*operations were renamed tosocial.*(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. Emitsstateandprogressframes,:pingkeepalives every 15 seconds, auto-closes on terminal status. - Three built-in agent profiles:
content_writer(end-to-end content creation, 25-turn budget, emitscontent_pieceartifacts),researcher(rigorous research with citations, 20-turn budget, emitsresearch_reportartifacts),copilot(general-purpose assistant, 15-turn budget, optional artifact output). - Three pause types on
awaiting_inputsessions:approval,question,continue_or_finish. Each carries a JSON Schema thatagents.resumevalidates 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_logwith the caller's identity and correlation id, matching the audit trail direct REST and MCP calls already produced.
Added
platform_audit_logtable. 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.querytool 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: truesentinel in the response body.
Changed
- Error responses now include
X-Correlation-Idon every status code, not just 5xx, so failed requests can be traced end to end.
Added
- Webhook extensions.
POST /api/platform/v1/webhooks/:id/testfires a singletest.pingdelivery without affecting production aggregates.GET /api/platform/v1/webhooks/:id/deliveriesreturns the delivery history with status filtering and pagination. is_testcolumn onplatform_webhook_deliveriesto separate test fires from production traffic.X-Webhook-Test: trueheader 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_countis incremented on the webhook.
Added
scope_modecolumn 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_artifactsand the legacywriting_samples,customer_posts,customer_testimonials, andstyle_collectionstables. Reads from either side return identical data; writes propagate automatically. platform_artifact_versionsappend-only version audit trail. Every write toplatform_artifactsinserts a corresponding version row.artifacts.gettool now supportsinclude_versions: trueto return the version history inline.
Changed
- Artifact writes must supply
expected_versionfor concurrent-edit safety. Mismatches return409 version_conflictwith 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 optionaldetails. - 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.