Docs

Changelog

RSS

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

Last updated 2026-10-08

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