feat: datasearch tool (Tako) with keyless free-tier fallback - #1175
feat: datasearch tool (Tako) with keyless free-tier fallback#117525eliu wants to merge 43 commits into
Conversation
- validate strict/node_ids contract in _sources before the API 400s - log coverage-drill failures instead of swallowing them silently - test coverage for the priced search/answer/contents paths and _sources - drop dead API_HOST constant
- Sources built with conditional kwargs so a skipped source is absent from the wire body, not serialized as null (data-count 0 promise) - data_count=0 with node_ids/strict now raises; negative and >20 counts rejected; skipping both sources rejected - effort/mode/content_format validated pre-network via SDK-derived enums; CLI surfaces client ValueErrors as one-line BadParameter messages - request timeouts via _api._request_timeout (120s priced, 30s graph) - NER_LABELS/NODE_TYPES derived from SDK enums instead of frozen copies - other_matches carry node_id; summary marks undrilled hits 'not checked' and says so explicitly when no drilled match has coverage
|
Addressed all six findings from @robertabbott's review in 698c16c (replying here because the review threads aren't visible to the API yet — will follow up inline once they are):
69 unit tests pass (up from 54); live free smoke ( @claude-address |
Without a TAKO_API_KEY, search/answer/available-data route to the free rate-limited MCP at mcp.tako.com (scaffold ahead of the server-side tier launch; anonymous calls surface a clear message until then). contents stays key-only. Same additive-credential shape as websearch's Parallel MCP fallback: routing via ctx.secrets membership, meta.backend + meta.partial_failures on every response, TAKO_API_KEY becomes an optional_secret, and mcp.tako.com joins the host allowlist.
…de deep effort Cross-checked _mcp.py against the tako-mcp worker tool schemas (workers/src/tools/) rather than assumptions: - tako_search's effort enum is fast/instant only, so effort=deep on the keyless path would hit a schema rejection server-side. The backend now records a meta.partial_failures entry and runs the search at the default instead (same degrade pattern as the answer path and the websearch tool's MCP mode fallback). - other_matches from the hosted tako_available_data carry name/type only (no node_id, unlike this tool's keyed graph pipeline). Docstrings, CLI help, and README now state the difference instead of promising node_id on both backends. - README documents the differing backend defaults (MCP: 10 results with inline row previews; API: 5 pointer cards) and records which schema facts were verified and that the final keyless contract lands with the unmerged tako-mcp anonymous-tier PR. Tests: updated the passthrough case to a supported effort and added a deep-degrade case asserting the argument is omitted and flagged (80 passed).
…ract The server side now exists: TakoData/tako-mcp#171 serves anonymous /mcp as a fail-closed free tier (3-tool surface, 10 metered tools/call per minute per client IP, 429 + JSON-RPC error + Retry-After when over). Align the backend with that contract: - 429 -> McpRateLimited, surfacing the server's own error.message verbatim (so the stated limit can never drift) plus the Retry-After hint and the TAKO_API_KEY remedy. Guarded on both the tools/call and handshake paths. - Drop the client-minted Mcp-Session-Id: the Worker is stateless, never issues one, and metering is per client IP, not per session. Requests now carry no auth or session headers at all — header ABSENCE is what routes to the free tier (empty/malformed would 401). - 401 message updated: it now means the tier is disabled on that endpoint (over-limit is 429, not 401). - README: per-IP metering caveat for NATed sandbox fleets (shared 10/min bucket -> fallback, not fleet capacity), and the verified contract note now cites the PR. Tests: 429 with server envelope, 429 with unparseable body, and a header-absence guard (no authorization / mcp-session-id on any request). 83 passed; ruff at the pre-existing baseline.
Tool secrets have no env signal inside a sandbox (placeholders are swapped by iron-proxy on the wire), so _is_configured cannot see the deployment vault. With the firewall active the client now tries the SDK path first and degrades to the free MCP tier once on 401/403, keeping zero-config deployments working while keyed ones are unaffected.
Routing experiment: the catalog lists bare names, so 'research' self-describes at discovery where 'tako' required reading --help. Help text now leads with web search plus the concrete licensed data domains (S&P Global financials, FRED macro, SimilarWeb traffic). Command name flows from _coverage.TOOL_COMMAND so the model-facing summary strings stay consistent.
Two routing levers for data queries that were defaulting to websearch: - the sandbox prompt's research-path guidance now names `research` for financials/market/macro/web-traffic lookups (the prompt previously recommended only websearch, in-context, before any catalog reading) - websearch's own --help cross-references `research` for those domains, intercepting at the moment of misrouting Cross-tool + prompt change; flagged for reviewer attention.
- command renamed research -> datasearch (project.scripts + TOOL_COMMAND); package/directory keep the tako name - reverted the websearch --help cross-reference (no cross-tool edit) - system-prompt routing line rewritten concise: financials/markets/ economy/web-traffic or web-live data -> datasearch
The live free tier returns a two-channel MCP response (readable text document + structuredContent payload), which the old fallback discarded — keyless search/answer/available-data returned empty. Now: - _mcp.py reads both channels: text -> answer_markdown/summary, structured -> cards/answer/found. Robust across tako-mcp#187 (bulk payload moving into structuredContent): works pre- and post-deploy. - SDK path gains answer_markdown (rendered from cards) so both backends return the same shape (matches the websearch answer_markdown pattern). - removed dead widget-stripping helper; tests model the real two-channel envelope; ruff clean (bar accepted load_dotenv/sys.path E402s). Verified live against mcp.tako.com: available-data/search/answer all return real readable content keyless.
Remove design docs (docs/superpowers/) and revert the SYSTEM_PROMPT.md routing line — neither is standard tool-PR content per AGENTS.md, which scopes tool contributions to the plugin under tools/. The datasearch routing preference, if wanted upstream, belongs in a separate prompt PR or a deployment persona overlay.
Every other tool under tools/research/ has a row in the Tool Directory reference; datasearch was missing. The public/md copy is hand-edited to match rather than regenerated, so unrelated stale generated pages stay untouched.
load_dotenv() ran before the module imports, which tripped four E402s. The client is imported lazily inside each command, so moving the call below the imports keeps the same behavior and clears ruff.
Head branch was pushed to by a user without write access
The SDK path returns the whole /v3/search body, which carries a top-level pub_id/image_url/embed_url pointer to the response's lead card. The MCP backend rebuilds its response dict and dropped those keys, so the free tier lost a chart pointer the paid tier keeps. It matters most in the pre-187 shape, where `cards` comes back empty and that pointer is the only thing naming the chart. Copied through when the server sends them, so both backends expose the same pointer. Additive: no existing key changes.
`datasearch slack-card` turns a search result into a Slack message and optionally posts it into the thread the turn belongs to. Presentation only: every card already carries its rendered chart in `image_url`, so this makes no Tako API call. Uses an `image` block. Slack scales it to the message width and preserves aspect ratio without cropping, so there is no geometry to fit. The headline (the card's own `description`) and the "Open in Tako" button sit outside the image, costing no image height, and interactivity is delegated to the button. The `video` block would render a live iframe but fixes the frame to a small box with no size controls, crops taller cards, loads only on click, is dropped from ephemeral messages, and needs links.embed:write on both tokens plus a registered unfurl domain. Nothing here reads, scales, or reformats a value: Tako already chose the axes, units, and currency, so its render and its description pass through untouched. Tests pin that. Both backends render identically, including the empty-`cards` case that falls back to the top-level pointer. `--post` posts directly with httpx rather than delegating to the slack tool: tools run under `uvx --from <project_dir>`, so a sibling tool's package is not importable. httpx honors HTTPS_PROXY and SSL_CERT_FILE from the environment, so no explicit iron-proxy wiring is needed. This binds SLACK_BOT_TOKEN and widens this tool's egress to slack.com, called out in the manifest comment; dropping both disables only `--post`, and the command still prints its payload for another tool to send. Verified against a live workspace: both layouts post, and the container block is kept rather than silently dropped.
The help and README named three licensed sources and trailed off with "and more", which read as a narrower tool than it is: the routing guidance covers roughly a dozen domains, so a sports or elections question could get routed to websearch on the strength of a help string. States coverage as five domains instead, and points at `available-data` as the authority on whether a specific entity or metric is covered. Sources are an implementation detail that changes without the coverage changing, and each card already carries its own attribution. The list has one definition, DOMAINS in _coverage.py, which the CLI help renders from and the README points at, so the two cannot drift again.
`slack-card` existed but nothing connected "this result has a chart" to "show it", so a data question in Slack returned text and the card never appeared unless the caller already knew the command. Two additions: - `--slack-card` on `search` and `answer` posts the lead card into the thread the turn belongs to, so one call retrieves and renders. - Without the flag, a result that has a card in a Slack thread gains a `slack_card.hint` naming the command that renders it. Both are conditional on being in a Slack thread with a renderable card, so Discord, Teams, and direct API turns are untouched, and web-only results stay unchanged. A failed post never loses the retrieved data: the result still prints and the failure lands in `slack_card.error`. Posting is a side effect of a read command, so it stays opt-in behind the flag rather than firing on every search.
`--slack-card` and the hint both gated on CENTAUR_THREAD_KEY, which is unset in every live sandbox: warm-pool pods start before a thread claims them and pod env cannot be changed afterwards. So neither ever fired, on search or on answer, and the flag was silently inert. The destination was never in the environment to begin with. It arrives in the turn's prompt, under session_context.slack.channel_id / .thread_ts, and `slack upload` already takes it as arguments; this now follows that precedent instead of sniffing env. - `--channel` / `--thread` on search and answer, passed through to the post. - The hint no longer requires a resolved destination. It cannot tell which chat surface a turn belongs to, so it is phrased conditionally and names the two options to pass; gating on the environment meant never showing it. - `--post` without a destination now says which values to pass and where they come from, rather than reporting an unset variable the caller cannot set. - CENTAUR_THREAD_KEY stays as a fallback for a dedicated sandbox that has it. Verified against a live workspace with the variable unset: the hint renders, and an explicit --channel posts.
The note was appended as the last key, which put it on line 156 of a 160-line search result. Callers pipe this through `head -80` or `head -100`, so the hint was cut off before it was ever read: the reason a data question in Slack still produced no card even after the destination fix. Emitting it as the first key puts it on line 2. Two tests pin it: the note is the first key, and it survives truncation of a realistic multi-card result.
The fixtures carried a real workspace team id, a real private channel id, and real message timestamps captured while testing. AGENTS.md prohibits adding chat workspace identifiers and asks for neutral placeholders in new examples. Switched to the placeholders the sibling test_slack.py already uses (T0123ABC, C0456DEF, 1754870000.001200) so both files read the same way.
The directory row still listed the licensed sources, contradicting the tool's own help and README after 5ab59e5. Same domain wording as DOMAINS.
`send_message` in the client has taken a `blocks` argument for a while, but
the CLI had no way to pass one, so a tool that renders Block Kit had no way
to post it. `--blocks-json` takes a file path or `-` for stdin.
Accepts either a bare blocks array or a whole `chat.postMessage` body with a
`blocks` key, so a renderer's output pipes straight in:
datasearch slack-card <card> | slack send eng-ai "Apple revenue" --blocks-json -
`message` stays required: with blocks it is the notification and old-client
fallback text, which Slack needs regardless.
Posting lives here rather than in the rendering tool because this tool
already owns the Slack credential. Tool secrets are scoped per tool
(tools/README.md), so a Tako tool holding a Slack bot token would cut
against that.
Posting from this tool never worked. Traced from a real sandbox:
secret("SLACK_BOT_TOKEN") returns the placeholder, the proxy allows egress to
slack.com and forwards the request, but no credential is substituted, so Slack
answers invalid_auth. That is the scoping working as intended rather than a
misconfiguration: tool secrets are scoped per tool (tools/README.md), and this
is a Tako tool.
So posting moves to the tool that already holds the Slack credential, and this
one renders only:
datasearch slack-card <card> | slack send <channel> "<fallback>" --blocks-json -
Removed: post_message, SlackPostError and its error mapping from _slack.py;
_slack_destination, --post, --channel and --thread from cli.py; the
SLACK_BOT_TOKEN binding and slack.com egress from the manifest, so this tool's
permissions match its purpose again. Net 120 lines lighter here for ten in the
slack tool.
`--slack-card` on search and answer now attaches the rendered message under
slack_card.message instead of sending it, keeping the one-call convenience, and
the payload pins no channel or thread since the posting tool supplies those.
The hint names the full pipeline.
Tako serves card images dark by default and stamps `dark_mode=true` on
`image_url` / `dark_mode=auto` on `embed_url`. Both are wrong for a Slack
message, which renders on each reader's own theme: a dark chart in a light
thread reads as a black box, and `auto` resolves against the fetcher's
environment rather than the reader's, so it is effectively arbitrary. One fixed
appearance beats a coin flip, and light is the safe one.
New _theme.py owns the rewrite, applied at two layers because neither alone is
sufficient:
- client.py, at the response boundary, so every card that leaves the tool is
already light and a new surface needs no theme code. Wrapped inside the
via_mcp closure so the keyless path and the 401-fallback path are covered by
one edit, and around the SDK lambdas for the keyed path.
- _slack.py, in card_urls. This is the one that mattered: `slack-card <pub_id>`
builds {"card_id": pub_id} with no image_url, so the payload pass has nothing
to rewrite and the URL is reconstructed unparameterized -- which renders dark.
The response-boundary fix alone left the primary flow dark.
Verified against production on 2026-08-11 (card vjvljW2ZZ3ImEsq1P-nI):
/api/v1/image/<id>/ with no parameter and with dark_mode=true return
byte-identical dark PNGs, dark_mode=false returns a genuinely light one, and
/embed/ likewise distinguishes true from false while serving auto as false. So
the parameter is honored today. The trap is that a *missing* parameter means
dark, so it has to be added, not merely overwritten -- the previous note in
_slack.py concluded the opposite from testing only dark_mode=true, and is
corrected here.
webpage_url is deliberately untouched: that link opens the live card on
tako.com, which should follow the visitor's own theme.
light_mode_url is idempotent, rewrites tako.com hosts only (a lookalike host or
a web result's url is returned unchanged), and apply_light_mode returns a copy
rather than mutating the payload. Six tests in test_slack.py pinned the old
pass-through behaviour and are updated to the new intent; the "never a rebuilt
image" invariant still asserts host, path and card id survive, and now also
pins the theme.
Review pass over this PR turned up three real defects, two of them in the light-mode change from the previous commit. **answer_markdown contradicted cards[].** `_add_search_markdown` writes `chart: <image_url>` into the prose it renders from the cards, and the theme pass ran around it rather than inside it, so on the SDK search path the prose quoted the original dark URL while `cards[]` carried the light one. Reordered so the pass runs first and the markdown is rendered from already-light cards. **The keyless path never pinned the prose at all.** `_mcp` forwards the hosted tool's own document, which names image URLs with no `dark_mode` parameter -- and a bare URL renders dark, so `answer_markdown` pointed at dark charts on the free tier no matter what `cards[]` said. Added `light_mode_markdown`, a text-level rewrite of Tako image/embed URLs, applied to `answer_markdown` and to `answer`. `answer` is normally a one-line synthesis, but `_mcp.answer` falls back to the whole text channel on servers predating tako-mcp#187, and that document does name image URLs, so it is covered rather than assumed clean. `/card/` links are left alone, matching the existing `webpage_url` rule. The URL pattern stops before markdown and sentence punctuation so a link inside `[text](url)` is not swallowed with its delimiter. Verified both paths end to end: SDK from a real sandbox, keyless against mcp.tako.com -- markdown URLs all light, quoting cards[0] verbatim, `/card/` links untouched. **`slack send --blocks-json` silently dropped requester attribution.** Slack renders `blocks` when present and demotes `text` to notification/fallback, so the attribution `send_message` appends to `text` was invisible in the message body. Latent until now (`blocks` was unreachable from the CLI); this PR makes it live. Attribution now also rides as a trailing context block, built on a copy so the caller's list is not mutated, and still suppressed by `--no-attribution`. Tests: `light_mode_markdown` and the markdown/cards agreement, the `answer` fallback, attribution-with-blocks including the no-mutation guarantee, and `_read_blocks`, which had no coverage at all despite being this PR's new entry point (bare array, full postMessage body, stdin, and each rejection path). 191 tako + 89 slack green; ruff clean on every file touched. Pre-existing lint and format drift in slack/cli.py and slack/tests/test_cli.py is left alone per AGENTS.md -- none of it is on lines this PR touches.
Follow-up: the card renders, but nothing tells the agent to post itFlagging a deliberate gap so it is a decision on the record rather than a surprise. What works. What does not. The agent usually ignores it. Traced on a real Slack turn (keyless sandbox, It read the hint and stopped. No Why. Nothing in the agent's instructions asks for the chart to be rendered into the thread. Deliberately not fixed here. Making it reliable needs roughly one line of agent instruction ("when a datasearch result carries a
Recommendation: keep this PR tool-scoped and do (1) or (2) separately. The tool side needs no further change either way — the hint is already emitted with the right card id and the right pipeline. |
`ThreadTarget` and `thread_target()` resolved the Slack thread a sandbox turn belonged to, so `--post` could default its destination. f292f35 moved posting to the `slack` tool and removed `--post`/`--channel`/`--thread`, which left the parser, its regex, and the `CENTAUR_THREAD_KEY` read reachable only from their own tests. Removed all three plus TestThreadTarget. This also takes the last `os.environ` read out of _slack.py, so the module is now pure rendering with no environment coupling at all -- easier to justify living in a research tool, and one less thing for a reviewer to ask about. 185 tako tests green (was 191; the 6 removed covered only the deleted parser).
Summary
New
tools/research/tako/plugin: datasearch — cited, chart-backed structured data across companies & financial markets, macroeconomics & government data, digital & industry intelligence, sports, polling & live events, and weather & climate, plus web results, via Tako. Coverage is stated by domain rather than by the licensed sources behind it: sources change without the coverage changing, each card carries its own attribution, and naming three made the tool read as narrower than it is. The list has one definition,DOMAINSin_coverage.py, which the CLI help and the tool-directory row render from. Installed as thedatasearchCLI; the package/directory keep thetakoname.Commands:
available-data(free coverage discovery),search/answer(priced structured+cited results),contents(dataset export),slack-card(render a card as a Slack message),health.Two backends, one shape
Selected by whether
TAKO_API_KEYis configured (never by key value — sandboxes hold only placeholders):tako-sdkagainsttako.com, full features.mcp.tako.com), same additive-credential pattern as websearch's Parallel fallback.contentsstays key-only.Both backends return the same shape:
search→answer_markdown(readable## Tako Datadocument) + structuredcards(image_url,webpage_url,node_ids,exportable, rowcontent) +meta;answer→answerprose +cards;available-data→found/summary/matches. The SDK path rendersanswer_markdownfrom its cards; the MCP path reads the hosted tool's two-channel response (readable text +structuredContent). Robust across tako-mcp#187 (bulk payload moving intostructuredContent): works before and after that deploys. Every response carriesmeta.backend(tako:sdk/tako:mcp) andmeta.partial_failures.Sandbox integration
TAKO_API_KEYbound toX-API-Keyontako.com; read via thesecret()placeholder mechanism.mcp.tako.comadded to the host allowlist for the keyless path.HTTPS_PROXYset the client takes the SDK path optimistically and falls back to the free MCP once on a 401 (remembered for the client's lifetime).X-API-Keydefault header (the SDK's OpenAPI spec omits auth on the beta graph endpoints).Rendering a card into Slack
slack-cardturns a card into a Block Kit message and prints it;search/answergain
--slack-card, which attaches the same rendered message underslack_card.message. When a result carries a card the payload also gainsslack_card.hintnaming the pipeline that posts it, emitted as the first keybecause a search result runs past a hundred lines of JSON and callers routinely
pipe it through
head -N.It renders an
imageblock pointed at the card's ownimage_url. Slack scales animage block to the message width and preserves aspect ratio without cropping, so
there is no geometry to fit, and the headline (the card's own
description) andthe "Open in Tako" button sit outside the image where they cost no image height.
Nothing here reads, scales, or reformats a value: Tako already chose the axes,
units, and currency, so its render and its description pass through untouched, and
tests pin that. Verified against a live workspace: both the
containerand flatimage/context/actionslayouts post, and Slack keeps the blocks as sent.Posting deliberately lives in the
slacktool, not here. This PR thereforetouches one file outside
tools/research/tako/:slack sendgains--blocks-json(a file path, or-for stdin; accepts a bare blocks array or awhole
chat.postMessagebody), about ten lines.Posting from this tool was implemented first and did not work. Traced from a real
sandbox:
secret("SLACK_BOT_TOKEN")returns the placeholder, iron-proxy allowsegress to
slack.comand forwardsPOST /api/chat.postMessage, but no credentialis substituted, so Slack answers
invalid_auth. Theslacktool's ownhealthfails identically in the same sandbox, so this is per-tool secret scoping
(
tools/README.md: "tool secrets are scoped") working as intended rather than amisconfiguration. Moving the send to the tool that already owns the Slack
credential removed ~120 lines here, plus the
SLACK_BOT_TOKENbinding and theslack.comhost entry from this tool's manifest, so its permissions match itspurpose again.
Two known limitations, neither introduced here:
belongs to, so the hint is phrased conditionally ("if this turn is in a Slack
thread") rather than suppressed, which would mean never showing it. Discord,
Teams, Linear and GitHub thread keys all resolve to no Slack target and degrade
without error; the retrieved data is never affected.
output_settings.image_dark_modeis accepted andreturns a distinct card id but does not change the rendered pixels, so a light
variant is not reachable today. Filed upstream as TAKO-3967.
Notes
pub_id/image_url/embed_urlpointer the SDK path passes through. It is copied over now, so a response whosecardscome back empty still names its chart and both tiers render identically (pinned by a test asserting byte-identical blocks).tests/(no__init__.py) so pytest's module naming doesn't shadow thetakoSDK package.AGENTS.md+ an adversarial pass; validated live against bothtako.com(keyed) andmcp.tako.com(keyless free tier).docs/pages/reference/tool-directory.mdxgains adatasearchrow, matching every other tool undertools/research/. The generateddocs/public/md/copy is hand-edited to match rather than regenerated, so unrelated stale generated pages stay untouched.Test plan
uv run --no-project --with pytest --with "tako-sdk>=2.2.6" --with httpx --with python-dotenv --with typer --with rich python -m pytest tools/research/tako/tests/test_client.py -v— 154 tests: coverage helpers, summary builder, available-data pipeline,_sourcescontract, priced-path shaping, input validation, both-channel MCP parsing, sandbox auth-fallback. No network.python scripts/validate_cli_packaging.py— passes (datasearch → centaur_tool_tako.cli:app).ruff check tools/research/tako— clean, zero findings (CLI imports reordered to thewebsearchpattern;load_dotenv()now runs after the imports, and the client is imported lazily inside each command so behavior is unchanged).uv build --wheel tools/research/tako— wheel contains only the nine source files; no.env,.venv, or caches leak in.TAKO_API_KEY):datasearch health,available-data,search,answer.mcp.tako.com):available-data,search,answerreturn real readable content.datasearch --helpfrom an installed wheel — Typer app resolves and listshealth,search,answer,contents,available-data.kind-centaur), perAGENTS.md, run against this PR's head (f292f359) — the local stack's repo-cache tracks this branch, so the sandbox ran the pushed source, not a local copy:centaur-tools list→datasearch /app/tools/research/tako, listed alongside itstools/research/siblings. This also confirms the catalog name isdatasearch, which is the name used in the Tool Directory row.datasearch --help→ Typer app resolves through the shim; all six commands listed, includingslack-card.datasearch healthanddatasearch available-data "Nvidia"→ real cited data returned through iron-proxy. With noTAKO_API_KEYin this deployment's vault, the client hit a 401 on the SDK path and emitted one line to stderr —tako: API rejected the placeholder credential (no TAKO_API_KEY in the deployment vault); falling back to the free MCP tier— then succeeded on"backend": "tako:mcp". That is the firewall-aware routing and one-shot 401 fallback described above, exercised end to end in-cluster.datasearch search "NVIDIA Corporation Revenue"→ documented shape confirmed on the wire:answer_markdown(## Tako Data (1 card)) +cardscarryingimage_url,webpage_url,exportable,content+meta.backend/meta.partial_failures.ruff check tools/research/tako tools/productivity/slack/cli.py— clean for the tako tool; the slack CLI's 17 pre-existing findings are unchanged before and after this PR's edit (verified by checking out the HEAD version at the same path and re-running).datasearch slack-cardoutput fed straight intoslack send's--blocks-jsonreader, which accepts it and returns the container block. The reader is unit-tested against a wholechat.postMessagebody, a bare blocks array, empty stdin, non-JSON, a missingblockskey, and an emptyblocksarray.chat.postMessage;response_metadatashows the blocks were kept, not silently dropped. Slack'signored_extra_attributes_for_image_blockwarning is benign and unavoidable — the minimal legal image block produces it, and omittingalt_textto silence it fails outright withmissing required field: alt_text.cardsintact in every case.slacktool's ownhealth. Not introduced by this PR and not fixable within it.