# SteadyBase documentation, in full > Every claim carries a verbatim quote and a link to the record it came from, or it returns nothing. > Generated 2026-09-20T02:19:47.871849+00:00. Sources are named under each section. ## Routes, called at build time - GET /v1/accounts -> 500 NOT AVAILABLE, do not depend on it - GET /v1/whats_new -> 500 NOT AVAILABLE, do not depend on it - GET /accounts/bulk -> 404 NOT AVAILABLE, do not depend on it - POST /accounts/resolve -> 404 NOT AVAILABLE, do not depend on it - POST /accounts/map -> 404 NOT AVAILABLE, do not depend on it - GET /v1/upcoming -> 500 NOT AVAILABLE, do not depend on it - GET /v1/search -> 200 answering Base: https://api.steadybase.io. Auth: bearer, issued by hand. Keyless rows: https://steadybase.io/company/data/index.json (4844 companies). ## MCP Endpoint https://mcp.steadybase.io/mcp, 53 tools. - resolve_entity (query*): Resolve any alias (ticker, company name, domain, or an entity_id) to the ONE canonical entity: entity_id, ticker, name, aliases, domains. - get_account (entity*): THE ACCOUNT CARD for one covered account: the receipted statements with their clips (account.signals, each with quote, source_url, receipt, transcript_url, page_ref, event_date, speaker and verified), the buying committee under `contacts` when the feed carries it (get_contacts returns the full list), the travel spend estimate with its drivers, green and red flags, the next earnings date and the rank. Echoes the canonical entity_id and ticker. WHAT IS QUOTABLE: only account.signals[].quote, and only with that row's receipt. The card's headline `quote` is a feed convenience: quote_receipt carries its receipt when one of the receipted statements matches it and quote_is_quotable says so, and when it does not, quote_receipt is null, quote_is_quotable is false and quote_receipt_note says to quote from signals[] instead. `why_it_matters` is a DERIVED sentence (why_it_matters_is_derived is always true): it is why the account ranks, not words the company said, and must never be put in quotation marks to a customer. There is no warm intro path on this card; the committee, where the feed carries it, is the nearest thing to one, and it is identity only. - get_signals (entity* since): Verified earnings-call quotes/signals for one account, each with date, source, and a verified flag. since=YYYY-MM-DD keeps only signals on or after that date. WHAT IS QUOTABLE: each row's `quote`, verbatim, with that row's own receipt (source_url, receipt, transcript_url, or the clip). `why_it_matters` on the same row is a DERIVED sentence (why_it_matters_is_derived is always true): it says why the statement matters and is not part of the quotation, so never put it inside the quotation marks. receipt_note says the same in words. - whats_changed (entity*): The recent-signals feed scoped to ONE account: its board row with rank, score, evidence tier, signal count, next earnings date, the headline quote and why_it_matters. WHAT IS QUOTABLE: the headline `quote` only when quote_is_quotable is true, and then with quote_receipt, which is the receipt of the one receipted statement it was cut from. When quote_is_quotable is false, quote_receipt is null, nothing on the row is quotable, and quote_receipt_note says so in words. `why_it_matters` is a DERIVED sentence (why_it_matters_is_derived is always true), not words the company said. For the verbatim statements with their receipts call get_signals(ticker), named on the row as verbatim_statement_from. - whats_new (feed since limit kinds): WHAT IS NEW SINCE I LAST CHECKED. Call it with no arguments for the classic board feed (top 50 board rows). On those rows WHAT IS QUOTABLE is the headline `quote` only when quote_is_quotable is true, with quote_receipt; when it is false, quote_receipt is null and quote_receipt_note says nothing on the row is quotable. `why_it_matters` on a board row is a DERIVED sentence (why_it_matters_is_derived is always true); the verbatim statements with their receipts come from get_signals(ticker), named on the row as verbatim_statement_from. Call it with `since` and it becomes the delta feed: every signal FIRST SEEN since that point, newest first, each row carrying event_key, ticker, company, kind, matched_rule, the verbatim quote, why_it_matters, source_url and page_ref, the receipt link, the signal date, evidence tier, and call_first (the one person to call, identity only, no email or phone). Poll it on a schedule: pass the previous response's `next_since` straight back as `since` and you get exactly what landed in between, with no duplicates and no gaps. When `truncated` is true the page is the OLDEST slice of the backlog and `next_since` is the newest event in it, so polling again immediately walks forward through the backlog; `next_action` says in words which of the two to do. `limit` is a soft cap on that path: a page is never cut in the middle of a group of events sharing one first_seen, because the cursor cannot resume inside one. `since` accepts an ISO timestamp (exclusive, so a cursor never replays its last row), a plain date YYYY-MM-DD (inclusive), or a number of days (7). event_key is the same identity the Slack and webhook push lanes use, so a pushed alert and a pulled row for one signal match by string equality. The feed carries direct-evidence signals and T&E spend-change matches (the grade a human gets told about); use search_signals for the wider inferential corpus. Retention is 30 days. THE LIVE FEED: everything above is the nightly board, so the freshest thing it can say is "last night". Pass feed="events" and the same tool answers from the box's live event log instead: WHAT HAS HAPPENED SINCE MY LAST CURSOR, oldest first, so a polling agent walks forward through it. Each row is event_seq, kind, event_key, occurred_at and payload. kind is one of trigger_hit (a trigger matched something written to your book), live_alert (a trigger matched what was said DURING a call, while the call is still running), clip_ready (the audio for an earlier live_alert has been cut, and the row carries the same alert_id plus the finished clip_url) and account_changed (an account on your book moved). A live_alert payload carries the VERBATIM QUOTE, the trigger it matched, the account, receipt_url and offset_sec, with clip_url null until the clip exists, so the quote always arrives with the receipt that opens it and you quote it as is or not at all. ON THIS FEED `since` IS AN EVENT_SEQ CURSOR, a string of digits, not a timestamp and not a number of days: pass the previous answer's next_cursor straight back and you get exactly what landed after it, because the cursor is EXCLUSIVE, so nothing is replayed and nothing is skipped. Start with no `since` at all, keep next_cursor from every reply, and poll. next_action says in words whether to poll again at once (truncated, there is a backlog) or on your schedule. `kinds` on this feed is that list of four and an unknown one is refused rather than ignored. The envelope stamps feed ("events" here, "board" on every other path) so you can tell the two apart on one key. The tenant is the one your key resolved to and no argument can name another. - upcoming_calls (none): Upcoming earnings calls with listen_for and your_move, stamped with the canonical entity_id. For the full capture calendar with confirmed times, webcast links and capture status, use upcoming_calendar. - upcoming_calendar (days ticker status event_type include_past_days limit): WHEN THE CALLS ARE. The capture calendar for this board: earnings calls and investor presentations with the date, the confirmed start time when the company's IR page published one, the webcast link, and our own capture status. Use it to answer "who reports this week" and to time outreach around a call. Status moves scheduled -> registered -> capturing -> captured -> transcribed -> extracted, or ends at missed (the event passed with no audio) or failed (we tried and got nothing); status_reason says why in a phrase. Two numbers in `summary` decide how much of this is actionable: with_webcast_url (no public link means the call cannot be captured at all) and with_confirmed_time (without it the start is a before-open / after-close guess, not a real clock time). Defaults to the next 30 days plus the last 7 so "what did we capture this week" is answerable in the same call. - next_earnings_call (ticker*): THE NEXT CALL FOR ONE COMPANY. Give it a ticker (a company name, domain or entity_id also resolves) and it returns the next scheduled earnings call on this board: date, days_until, the confirmed time in US Eastern when one is published, time_source (ir_page means a real published time; nasdaq_bmo / nasdaq_amc mean before-open / after-close and are a guess), the webcast_url to listen on, the IR page, whether it is capturable, and our capture status. Also returns the two calls after it and any other investor events for that company. Pair it with whats_new: a fresh signal plus a call inside the week is the moment to reach out. - get_actions (entity): The System of Action queue: ranked do-next moves for the top of your board, each with its canonical entity_id, a sentence, the headline quote, and the artifact that opens it when there is one. `sentence` is the row's why_it_matters, a DERIVED sentence (why_it_matters_is_derived is always true): it is why the account ranks, never words the company said, and must not be put in quotation marks. WHAT IS QUOTABLE: `quote` only when quote_is_quotable is true; then quote_receipt is the receipt of the one receipted statement it was cut from and artifact_url_or_text is the link that opens it. When quote_is_quotable is false, artifact_url_or_text and quote_receipt are null and quote_receipt_note says nothing on the row is quotable. The verbatim statements with their receipts come from get_signals(ticker), named on the row as verbatim_statement_from. Pass entity to scope to one account. - get_contacts (ticker*): The buying-committee contacts for one account on your board: identity only (name, title, persona, LinkedIn). Never email or phone. Pass a ticker. - get_priorities (tier limit): THE TIERED PRIORITY LIST (P1 act now, P2 direct evidence smaller prize, P3 big prize watch the trigger, P4 monitor). Each account carries its rank score decomposition (signal strength x qualification x spend tier), its green flags, its red flags (which demote and never disqualify), the business line, the motion, the travel spend estimate and top_rule, the rule that fired. NOTHING ON THESE ROWS IS QUOTABLE: why_it_matters and business are derived sentences describing why the row ranks, why_it_matters_is_derived is always true, quote and source_url are always null, and receipt_note says the same in words. For the verbatim statement behind top_rule and the receipt that opens it, call get_signals(ticker) or get_account(ticker) and quote from signals[]. Filter by tier, cap with limit. - research_company (name*): Live universe pull for ANY public company, even one NOT on the book: latest earnings-call transcript excerpt, labeled not-on-book. For a covered account use get_account instead. - search_content (query*): Search the GTM content library (battlecards, decks, one-pagers, the vault) and return the top matching docs with name, url, and tags. - today (none): THE LIVE CONSOLE (steadybase.io/today/). Today's calls with arm state (scheduled, armed, capturing, captured, or missed with a reason), the last 24 hours of company-level signals, today's landed filings/decks/transcripts with counts and receipts, the platform heartbeat strip, and the local director's newest operating note. Same JSON as https://steadybase.io/today/index.json, refreshed every 15 minutes. Public and NOT board-scoped: carries no tenant name, trigger text, contact, or email, whichever key calls it. - search_signals (query* ticker limit): Full-text search across every signal receipt on your board - verbatim quotes from earnings calls, deck slides (page-linked), filings, and job postings. Each hit returns the quote and its source link (audio clip with timestamp when captured). - find_companies (query q include_tree resolve external_id external_system bulk cursor since include_unresolved book_only limit): FIND, RESOLVE OR LOAD COMPANIES. Four modes on one tool, by which argument you give, in this precedence: resolve, then bulk, then q, then query. (1) resolve is THE IDENTITY RESOLVER and wins over q and query. Give it an object of identifiers, any combination of account_id, cik, lei, ticker, exchange_ticker, domain, linkedin_company, legal_name (with country or state to disambiguate), parent_hint and duns, and you get back ONE account_id with method (which rung matched), confidence, corroborated_by, the account row, the corporate tree (parent, ultimate_parent, is_ultimate_parent) and the evidence list of every rung that was tried. A miss is an EXPLICIT no match: resolved false, reason no_match or ambiguous or conflict, and the nearest candidates so you can see what it nearly was. TWO CANDIDATES IS AMBIGUITY AND NEVER A MATCH: this tool does not promote a candidate into an answer, and you must not either. Send a stronger identifier or a jurisdiction and ask again. (2) external_id with external_system resolves YOUR OWN id (a Salesforce 18 character id, a CRM account key) through the same route, answered from your tenant's own id map first so a mapped id does not get re-matched. Both arguments go together, and the map read is scoped to the tenant your key resolves to; a key that is not mapped to a tenant is told so and the box is never called. (3) bulk true is THE ACCOUNT UNIVERSE, paged: identity only, no signal, no score, no tier, no tenant lens, in account_id order. Each row carries account_id, legal_name, display_name, primary_ticker, primary_domain, account_type, lifecycle_status, resolution_status, is_company, parent_account_id, ultimate_parent_id, depth, hq, sector, industry, exchange, employees_est, ir_url, identifiers (cik, lei, ticker, exchange_ticker, domain, linkedin_company, former_name, duns) and updated_at. Page on next_cursor until has_more is false; take deltas by passing since = the previous answer's as_of. include_unresolved adds the 209,093 EX-21 exhibit nodes we hold as evidence and have not identified (they come back resolution_status unresolved and is_company false, and they are NOT companies we assert exist under that name); the default scope without it is 10,140 resolved rows out of 219,233 total. book_only narrows to your own book. YOU DEDUPE ON account_id AND NOTHING ELSE. Ticker collisions (two companies that have held one symbol at different times), name variants, share classes and a subsidiary sharing its parent's domain are OURS to collapse and are already collapsed onto one account_id before a row leaves here. account_id is stable across a rename, a ticker change, an exchange move and an acquisition. Never dedupe on ticker or name. If two rows ever carry the same identifier under the same id_type, that is a bug to report to us, not a merge for you to perform. PAGE SIZE: the MCP page defaults to 200 rows and is capped at 500, because one tools/call result is one text block that lands whole in your context and 1,000 of these rows is about 200,000 tokens. The box itself will serve 5,000, so for the FIRST FULL LOAD of all 219,233 rows ask for the nightly export instead: one gzipped JSONL file per tenant in the private snapshots bucket, delivered by a presigned URL, built by the same function as this page so the two cannot drift. Use bulk for deltas and for reading a slice. (4) query is the PUBLIC DIRECTORY substring search over all 7,034 published companies by name, ticker or slug, case insensitive, exact ticker first, the same answer for every key: it returns slug (the durable key for the other company_* tools), name, ticker, url, page_url and the signal, quote and people counts. Use it when you have a company name and need the slug. (5) q is the IDENTIFIER LOOKUP behind your key: a domain, ticker, LEI or name, and each row is the company profile, account_id, name, legal_name, ticker, domain, sector, industry, exchange, cik, lei, hq_city, hq_state, hq_country, ir_url, linkedin_url, employees_est, on_book (whether that account sits on YOUR book), matched_on (which identifier hit) and identifiers (the rest of them). THE RECEIPT on a q row is cik and lei, the registry identifiers you can check the company against, plus ir_url and linkedin_url. include_tree true adds the corporate tree stats: parent, ultimate_parent, children_count, subsidiaries_count, and the people_count, roles_count, signals_count, documents_count and transcripts_count held. LIMITS: limit is max rows, default 10 and cap 100 on q and query, default 200 and cap 500 on bulk. The tenant on every keyed path is the one your key resolved to and cannot be overridden by an argument. bulk cannot be combined with resolve, q or query and says so rather than answering a narrower question. IT NEVER RETURNS an email, a phone, a tier, a score, a rank or any prioritisation of any kind. - get_company (slug_or_ticker* include_people): THE COMPANY PAGE, AS DATA. One company from the public directory: identity (canonical ticker, aliases, domain, sector, industry, size band, IR and LinkedIn URLs), counts, the calendar (next event and recent events with webcast links), recent hiring, leadership_changes from 8-K filings, why_now and family when the export publishes them, and the newest 25 signals each with a verbatim quote and a receipt. Accepts a slug or a canonical ticker; a share-class ticker such as BAC^N resolves to its canonical company. people are OFF by default: pass include_people=true and you get name, title, role, linkedin_url, aliases and their sourced quotes, and nothing else. Never returns an email or a phone number, and carries no prioritisation of any kind. - company_why_now (slug_or_ticker*): WHY THIS COMPANY, NOW. Returns the published why_now packages when the export carries them, with derived_from set to "why_now". When no package is published it does NOT invent one: it returns the 5 most recent public statements the company made, each with a verbatim quote and an https source_url, and marks the response derived_from="signals" so a caller can always tell an authored why-now from raw evidence. Read that marker before you quote this anywhere. - company_signals (slug_or_ticker* lane since limit): THE RECEIPTS. Every public signal on one company, newest first, each with the verbatim quote, an https source_url, page_ref, event_date and lane. Filter with lane (earnings_call, filing, ir_statement, exec_social, hiring, engineering) and with since (YYYY-MM-DD, inclusive floor on event_date). lanes_available tells you which lanes this company actually has. Duplicate statements are collapsed, keeping the entry with the best receipt. Use it after get_company when 25 signals is not enough or you want one lane only. - search_company_signals (query* lane since ticker_or_slug limit): SEARCH EVERY COMPANY AT ONCE. Full-text search across the public signal index: every verified signal plus every signal on a lane other than earnings_call and earnings_calendar, across all companies in the public directory, each with a verbatim summary, an https source_url, page_ref, event_date, lane and the company page URL. This is the tool for "which companies said anything about a hiring freeze", where find_companies and company_signals both need you to know the company first. Matching is exact lowercased token match, no stemming and no synonyms, so use literal words. Ranked by how many of your query terms a signal carries, then by recency. Filter with lane (call list_lanes for what exists), since (YYYY-MM-DD inclusive floor on event_date) and ticker_or_slug (one company). Not board scoped: the same answer for every key, because the index it reads is public by contract. - list_lanes (none): THE LANES, WITH COUNTS. Every signal lane in the public cross-company index and how many signals each holds, plus the total rows, the number of companies covered, the date window, and when the index was generated. Call it before search_company_signals when you want to know what a lane filter can legally be, or to tell a caller how fresh the corpus is. - get_receipt (url transcript_id start end): RESOLVE A RECEIPT. Every earnings-call quote in this MCP and in the public company directory carries a source_url. When that URL has the form https://api.steadybase.io/v1/receipt/?start=&end= the transcript is licensed text that is not public, and this tool is how a key holder reads it: pass the url (or transcript_id plus start and end) and get back exactly the quoted span from the stored transcript, with the character offsets it was cut at and the transcript's size. Omit start and end to read the whole transcript (capped at 400k characters). Use it to verify a quote verbatim before repeating it, or to read the paragraph around it (widen start and end). Receipts on our own captures are plain public S3 URLs and do not need this tool. - get_stated_te (ticker*): Best stated T&E/travel spend for a public ticker (amount, quote, source URL). Never invents. Jets out of scope. - list_stated_te (qa_status require_amount limit): List stated T&E calibration rows. Sparse receipts; full product is the active public book. - get_gbv_estimate (ticker* model_id): Annual managed air+hotel GBV range (jets excluded) for a public ticker. public_company v0, or pending if that ticker has no fill. - get_gbv_priors (stratum): Versioned GBV priors by stratum when published. - gbv_feed_stats (none): Stated T&E and public book feed counts loaded by MCP. - sf_why_now (limit): YOUR WHY-NOW LIST FROM SNOWFLAKE. The Why Now catalog ranked through your tenant's prefs, rules and book, read as your tenant's read-only role from GTM_SIGNALS.TERRITORY.V_TENANT_WHY_NOW: TENANT_RANK, TICKER, COMPANY, SIGNAL_TYPE, AS_OF, TENANT_SCORE, WHY_PLAIN and the receipt (RECEIPT_URL, RECEIPT_KIND, SIGNAL_KEY). Same rows a direct Snowflake connection with role GTM_SIGNALS_RO_ returns. - sf_account_signals (ticker account_id since_days limit): VERIFIED SIGNALS ON ONE ACCOUNT FROM SNOWFLAKE. Every verified shared signal on one account in your book, weighted by your lens, from V_TENANT_ACCOUNT_SIGNALS: SIGNAL_TYPE, EVENT_DATE, SUMMARY (verbatim), DIRECTION, TENANT_SIGNAL_SCORE and the receipt (SOURCE_URL, PAGE_REF, VERIFIED). Give a ticker, or an account_id (acc_...) which is resolved inside your own views. - sf_future_events (days capture_plan limit): UPCOMING CALLS ON YOUR BOOK FROM SNOWFLAKE. One row per upcoming event on your book accounts from V_TENANT_FUTURE_EVENTS: REPORT_DATE, CALL_TIME_ET, TIME_SOURCE, WEBCAST_URL, IR_PAGE_URL, REGISTERED, CAPTURE_PLAN (armed, deferred, no_time, no_url) and the calendar source. capture_plan filters to one plan; capture_plan_counts summarises the page. - sf_people (ticker account_id persona limit): PEOPLE AT A BOOK ACCOUNT FROM SNOWFLAKE. Identity only (FULL_NAME, TITLE, LINKEDIN_URL, persona match, time in role) from V_TENANT_PEOPLE, never an email or a phone. Give a ticker or account_id, and optionally a persona (a persona key such as cfo, controller, treasury, procurement, or a fragment of its label) to filter; persona alone lists that persona across your book. - sf_search_transcripts (query* ticker since_days limit): SEARCH YOUR BOOK'S CALL TRANSCRIPTS. Hybrid (vector plus keyword) search over about 120 word windows of every earnings call transcript on your book accounts. Answered by the box search service (Postgres plus local embeddings, same as search_transcripts_local; envelope mode local_*) when it is configured first, otherwise by your tenant's own Snowflake Cortex Search service (mode cortex_search). Each hit is verbatim TEXT with its receipt: TRANSCRIPT_URL plus CHAR_START/CHAR_END (offsets into the transcript), START_SEC when the call is timed, TRANSCRIPT_ID, EVENT_ID, TICKER, CALL_DATE. Quote TEXT as is or not at all. Filter with ticker and since_days. - sf_ask (question*): ASK THE SEMANTIC VIEW A QUESTION. Runs a natural language question through Snowflake Cortex Analyst over GTM_SIGNALS.SEMANTIC.GTM_SIGNALS_SV when the account exposes that view to your tenant role, and executes the generated SQL as your tenant role; the reply carries the interpretation, the SQL, the rows and their receipt columns. When the view is not exposed to your role it does not guess: it returns the view's tables, dimensions and metrics with a note, so you can put the question to the other sf_ tools. - search_transcripts_local (query* ticker since_days limit mode): SEARCH YOUR BOOK'S CALL TRANSCRIPTS ON THE BOX (no Snowflake). Hybrid full text plus local embedding (nomic-embed-text-v1.5) search over the same 120 word windows as sf_search_transcripts, served from Postgres on AI-Powerhouse and scoped to your book there. Same hit fields and receipts (TRANSCRIPT_URL plus CHAR_START/CHAR_END, START_SEC when timed, TRANSCRIPT_ID, EVENT_ID, TICKER, CALL_DATE) plus scores {fts_rank, cosine, rrf}. Quote TEXT as is or not at all. mode: hybrid (default), keyword or vector. - define_trigger (name* criteria_text* scope filters active): DEFINE WHAT YOU NEED TO HEAR ABOUT, IN YOUR OWN WORDS. Creates or updates (by name) one trigger for your tenant: criteria_text is plain language ("consolidating expense or travel tools"), scope is transcripts, signals, documents or all, filters is an optional JSON object (tickers, sectors, date_from, date_to, min_score, min_lexical, lookback_days). Written through your tenant's own owner's rights procedure SP_DEFINE_TRIGGER_, so it can only ever belong to your tenant. The hourly runner then searches the call transcripts on your book (Cortex Search, receipts kept) and the verified signal set by signal type, and writes hits you read with trigger_hits. Returns the TRIGGER_ID and the row. - list_triggers (include_inactive limit): YOUR TRIGGERS. Every trigger defined for your tenant from V_TENANT_TRIGGERS: TRIGGER_ID, NAME, CRITERIA_TEXT, SCOPE, FILTERS, the signal types the runner mapped the words to, ACTIVE, LAST_RUN_AT, LAST_HIT_AT and HIT_COUNT. - trigger_hits (trigger_id since source_kind limit): HITS WITH RECEIPTS. What your triggers matched, from V_TENANT_TRIGGER_HITS, newest first: TRIGGER_NAME, TICKER, SOURCE_KIND (transcript, signal, document), EVENT_DATE, SCORE, the verbatim QUOTE, CHAR_START/CHAR_END and RECEIPT_URL (the transcript's receipt URL, keyed for purchased text and readable with get_receipt, or the signal's source URL). Quote QUOTE as is or not at all. since floors MATCHED_AT (default 30 days back); trigger_id narrows to one trigger. - run_trigger_now (trigger_id*): PREVIEW A TRIGGER NOW. Runs the transcript part of one trigger synchronously as your tenant role over the call transcripts on your book (your tenant's Cortex Search service, or a keyword scan of the secure transcript view when that service is not serving), capped at 20 hits, nothing written. Each hit is verbatim TEXT with TRANSCRIPT_URL, CHAR_START/CHAR_END and a SCORE. The hourly runner persists the real hits; read those with trigger_hits. - pg_why_now (limit): YOUR WHY-NOW LIST FROM THE BOX (Postgres, no Snowflake). The Why Now catalog ranked through your tenant's prefs, rules and book, from gtm_signals.v_tenant_why_now on AI-Powerhouse: TENANT_RANK, TICKER, COMPANY, SIGNAL_TYPE, AS_OF, TENANT_SCORE, WHY_PLAIN and the receipt (RECEIPT_URL, RECEIPT_KIND, SIGNAL_KEY). Same rows and order as sf_why_now. - pg_account_signals (ticker account_id since_days signal_type q limit): VERIFIED SIGNALS ON YOUR BOOK FROM THE BOX (Postgres), one account or all of them. Every verified shared signal on your book accounts, weighted by your lens, from gtm_signals.v_tenant_account_signals: SIGNAL_TYPE, EVENT_DATE, SUMMARY (verbatim), DIRECTION, TENANT_SIGNAL_SCORE and the receipt (SOURCE_URL, PAGE_REF, VERIFIED). Give a ticker or an account_id (acc_...) for one account, or give NEITHER and it answers across the whole book, which is how you ask "which companies ON MY BOOK announced a cost programme", "who changed an expense or travel vendor", "who is hiring into finance". signal_type is the lane: cost, cost_cutting, efficiency, expansion, geo, headcount, hiring, vendor_change, budget_spend, initiative, guidance, leadership_change, sec_filing, pain_point, other. q narrows to signals whose verbatim SUMMARY contains every word you give (up to 8 words, no stemming), so signal_type=cost with q=travel is the T&E cost question. This is the BOOK door onto lane search; search_company_signals is the same idea over the anonymously public cross company directory, and by contract that surface cannot tell you whose book an account sits on. An empty answer says which filter emptied it and nothing is widened to fill it. - pg_deals (ticker account_id status since limit): M&A ON YOUR BOOK, WITH THE FILING (Postgres). Every deal touching an account on your book from gtm_signals.v_deal_status, newest event first: DEAL_STATUS (announced, pending, closed, terminated), OTHER_PARTY_NAME, ANNOUNCED_DATE, EXPECTED_CLOSE, LAST_EVENT_TYPE and LAST_EVENT_DATE, PERCENT when the filing prints an ownership percent (no row carries one today, measured 0 of 604), the SOURCE_FORM and ITEM it was read from (8-K, 425, S-4, DEFM14A, 13D, tender offers), and two sentences quoted verbatim from the filing at RECEIPT_URL: RECEIPT_SENTENCE, the sentence the deal was read from, on every row, and CONSIDERATION_SENTENCE, what is being paid, when the filing states it (344 of 604 rows today). Ask it when you want to know which accounts are mid deal, because an acquisition is when a travel and expense programme gets consolidated onto one vendor. A deal with no receipt is NOT returned. Nothing is authored here: the status and the sentence are what the filing says, and OTHER_PARTY_MATCH tells you how the counterparty was resolved, so a name with no match is a name from a filing and not an account of ours. Filter with ticker or account_id, status, and since (YYYY-MM-DD floor on the last event). - pg_future_events (days capture_plan limit): UPCOMING CALLS ON YOUR BOOK FROM THE BOX (Postgres). One row per upcoming event on your book accounts from gtm_signals.v_tenant_future_events (fin45.future_events_current, refreshed hourly): REPORT_DATE, CALL_TIME_ET, TIME_SOURCE, WEBCAST_URL, IR_PAGE_URL, REGISTERED, CAPTURE_PLAN (armed, deferred, no_time, no_url) and the calendar source; capture_plan_counts summarises the page; cursor is the newest change_id for pg_future_events_changes. - pg_future_events_changes (cursor since limit): WHAT CHANGED ON YOUR BOOK'S UPCOMING CALLS SINCE A CURSOR (the replacement for the Snowflake FUTURE_EVENTS_STREAM). Rows of fin45.future_events_changes on your book accounts after change_id `cursor`: CHANGE_KIND (insert, update, delete), CHANGED_AT, TICKER, REPORT_DATE, CHANGED_COLUMNS, BEFORE_ROW, AFTER_ROW. Keep next_cursor and pass it back next time; nothing is consumed server side. - pg_people (ticker account_id persona title seniority function q include_email cursor new_in_role_days limit): WHO IS AT AN ACCOUNT, AND WHO HOLDS A PERSONA ACROSS YOUR BOOK (Postgres on the box). Answers "who are the finance leaders at UBER", "every controller on my book", "who has treasury in their title at uber.com". TENANT SCOPED: the tenant is the slug your key resolved to and cannot be overridden by an argument. Two shapes, one tool. Give ticker or account_id alone, with or without persona, and you get the classic gtm_signals.v_tenant_people read, unchanged: the upper case columns FULL_NAME, TITLE, LINKEDIN_URL, PERSONA_KEY, PERSONA_LABEL, time in role and SOURCE / SOURCE_REF. Add any of title, seniority, function, q, include_email or cursor and it answers the roster search instead, one row per person: person_id, full_name, title, headline, seniority, function, account_id, account_name, primary_ticker, linkedin_url, persona_key, persona_label, first_seen, last_seen, source, source_ref. THE RECEIPT on a roster row is source plus source_ref, the filing or vendor record the row came from, with linkedin_url as the identity you can check the person against. ticker also accepts a domain or a company name on the roster search. NEW IN ROLE: new_in_role_days answers "which of my accounts got a new CFO or controller in the last 90 days" on the book view; it returns only people whose current role started inside the window and never a person whose start date is unknown. ONE ROW PER PERSON PER PERSONA on the book view, which the box guarantees: a book that carries several tickers for one account (XOMA, XOMAO, XOMAP) does not repeat a person. LIMITS: limit is max rows, default 100, cap 500; paging is by cursor, which is EXCLUSIVE, so keep next_cursor from the reply and pass it back to get the next page without replaying or skipping a row; persona, title, seniority and function narrow, q is free text over name, title and headline. IT NEVER RETURNS a phone, a personal email, or any email at all unless your key carries contact entitlement AND you passed include_email true; when it does, business_email arrives with email_validated_at and email_risk. When it does not, the field is simply absent and `entitlement` in the envelope says why. - pg_ask (question metric dimensions filters limit): ASK THE POSTGRES SEMANTIC LAYER A QUESTION (no Snowflake, no model call). A plain question is mapped through the context layer's synonyms to one metric plus dimensions and run by scripts/semantic_query.py as your tenant; or send it structured: metric (e.g. signals.verified_signal_count), dimensions (e.g. [accounts.ticker]), filters (e.g. ["signals.event_date >= this_quarter"]). When the words name no single metric the reply is the catalog (tables, dimensions, metrics) and a note, never a guess. The reply carries mode, the SQL and the rows. - pg_define_trigger (name* criteria_text* scope filters active): DEFINE WHAT YOU NEED TO HEAR ABOUT, ON THE BOX (Postgres). Creates or updates (by name) one trigger for your tenant in gtm_signals.tenant_triggers: criteria_text in plain language, scope transcripts | documents | signals | all, filters an optional JSON object (tickers, sectors, date_from, date_to, min_score, min_lexical, lookback_days). The tenant is fixed from your key. The hourly runner then searches your book's call transcripts (hybrid search on the box), documents (filings, decks) and verified signals and writes hits you read with pg_trigger_hits. Returns the TRIGGER_ID and the row. - pg_list_triggers (include_inactive limit): YOUR TRIGGERS FROM THE BOX (Postgres). Every trigger defined for your tenant: TRIGGER_ID, NAME, CRITERIA_TEXT, SCOPE, FILTERS, the signal types the runner mapped the words to, ACTIVE, LAST_RUN_AT, LAST_HIT_AT and HIT_COUNT. - pg_trigger_hits (trigger_id since source_kind limit): HITS WITH RECEIPTS FROM THE BOX (Postgres). What your triggers matched, from gtm_signals.v_tenant_trigger_hits, newest first: TRIGGER_NAME, TICKER, SOURCE_KIND (transcript, signal, document), EVENT_DATE, SCORE, the verbatim QUOTE, CHAR_START/CHAR_END and RECEIPT_URL (the transcript's receipt URL, keyed for purchased text and readable with get_receipt, the document's page URL, or the signal's source URL). Quote QUOTE as is or not at all. since floors MATCHED_AT (default 30 days back); trigger_id narrows to one trigger. - pg_run_trigger_now (trigger_id*): PREVIEW A TRIGGER NOW ON THE BOX (Postgres). Runs one trigger synchronously over its whole lookback window on your book (transcripts by hybrid search, documents and signals by keyword), capped at 20 hits, nothing written. Each hit is verbatim QUOTE with RECEIPT_URL, CHAR_START/CHAR_END and a SCORE (DETAILS.score_kind says which). The hourly runner persists the real hits; read those with pg_trigger_hits. - set_trigger_active (trigger_id* active*): PAUSE OR RESUME ONE OF YOUR TRIGGERS. Sets active on one trigger of your tenant in gtm_signals.tenant_triggers: false stops the hourly runner on it without losing a single hit already written, true starts it again. trigger_id is a trg_<64 hex> from list_triggers or pg_list_triggers. Returns the trigger row read back (TRIGGER_ID, NAME, CRITERIA_TEXT, SCOPE, FILTERS, ACTIVE, LAST_RUN_AT, LAST_HIT_AT, HIT_COUNT) so you can confirm the new state. The tenant is fixed from your key: a trigger_id that is not yours is refused, never silently applied to another tenant, and nothing is deleted. - search (query* kinds limit since since_days tickers mode): ONE SEARCH ACROSS EVERYTHING THE BOX KNOWS (Postgres on AI-Powerhouse, no Snowflake, no model call, no paid API). The same unified search the portal's Search page runs, so the two surfaces cannot disagree: one query, four groups in one envelope. TENANT SCOPED. companies are the public company directory, the same identity for every tenant, with on_book saying whether that account sits on YOUR book; people, transcripts and documents are your book only and nothing else. Groups and their receipts: companies carry account_id, display_name, primary_ticker, domain, sector, exchange, on_book, and receipt.identifiers (each id_type ticker, cik or domain) plus receipt.cik and receipt.edgar_url; people carry FULL_NAME, TITLE, headline, account, persona and time in role, with receipt.linkedin_url, receipt.source and receipt.source_ref; transcripts carry the verbatim call window with TRANSCRIPT_URL plus CHAR_START/CHAR_END (START_SEC when the call is timed), TRANSCRIPT_ID, TICKER, CALL_DATE; documents carry the verbatim chunk with receipt_url or source_url, page number and char offsets. A transcript or document with no receipt URL is not returned at all. Quote the verbatim text as is or not at all. kinds is a list or comma string of companies, people, transcripts, documents (company, person, calls and docs are accepted too) and defaults to all four; limit is PER GROUP, default 10, cap 50; since (YYYY-MM-DD) or since_days bound transcripts and documents only, default 365 days back, and companies and people have no date; tickers narrows transcripts and documents. The envelope carries groups, counts, row_count, receipt_note, scope_note, timings and a per group errors list, so one slow group failing never loses the rest. It does NOT return a tier, a rank, an account score, an email or a phone, and it never returns another tenant's people, transcripts, documents or book membership. - bulk_company (ticker account_id since limit): EVERYTHING THE BOX HOLDS ON ONE COMPANY, IN ONE CALL (Postgres). The whole account as your tenant sees it, so an agent does not have to make eight calls: account (display_name, primary_ticker, domain, on_book), identity (sector, industry, country, exchange, cik, hq, ir_url, linkedin_url, employees_est, ipo_year), identifiers (id_type, id_value, is_primary), family (parent and children), people from your book view (identity only, with LINKEDIN_URL and persona), verified signals with SOURCE_URL and PAGE_REF, transcripts one row per call with its receipt_url and call_date, documents one row per document with doc_type, accession and receipt_url, upcoming events with WEBCAST_URL and IR_PAGE_URL, and YOUR trigger hits on the account with their quotes and receipts. Give a ticker or an account_id (acc_<32 hex>); an unknown one is refused, not guessed. since (YYYY-MM-DD) floors the dated lists. Each list is capped at limit separately, default 200, cap 500, and counts plus row_count say what came back. It does NOT return an email, a phone, a tier, a rank or an account score, and the people, signals and trigger hits are only ever your tenant's. - bulk_since (cursor limit kinds): EVERYTHING NEW SINCE A CURSOR (Postgres). The poll an agent runs on a schedule to stay current without re-reading the book: transcripts loaded, documents loaded, verified signals ingested, trigger hits matched and people first seen, all on your book, oldest first. cursor is an ISO timestamp and is EXCLUSIVE; keep next_cursor from the reply and pass it back next time, which never replays a row and never skips one. Omit cursor for the last 24 hours. A cursor older than 90 days is refused, so backfill with bulk_company instead. Rows carry their receipts (transcripts receipt_url and call_date, documents receipt_url, doc_type and accession, signals SOURCE_URL and PAGE_REF, trigger hits the verbatim quote with its receipt). kinds narrows which lists are built. Each list is capped at limit separately, default 200, cap 500, and truncated says which list was cut so you know to poll again at once rather than waiting; next_action spells that out. It does NOT return an email, a phone, a tier or a rank, and never another tenant's rows. # get a key (https://steadybase.io/docs/get-a-key/) Source: GET_A_KEY.md sha256 526383abeb4fc068cf5ba9c49a8f769f ## 1. How a key is minted, rotated and revoked today Three scripts, all in the fin45 intelligence-ingest repo, all run from the box in the venv. ### Mint: scripts/issue_tenant_key.py For a new tenant, or an extra key on a tenant that already has one. ``` .venv\Scripts\python.exe -m scripts.issue_tenant_key ^ --tenant-slug andrew_lab --tenant-name "Andrew lab" ^ --book book:the tenant --book PL,ROMA,AMBQ ^ --entitlement navan_read ^ --board-prefix app/tenants/andrew_lab ^ --rate-per-min 60 ^ --secret-name gtmsignals/tenant-keys/andrew-lab ^ --dry-run ``` Swap --dry-run for --apply to write. One apply does six things, in order: - Refuses a board prefix that equals, sits inside, or contains another live tenant's prefix. Nesting is how a list, a sync or a lifecycle rule over one tenant's namespace reaches another tenant's objects. The check runs against the live gtms-keys table, so it catches a typo before the tenant exists. - Mints gtms_ plus secrets.token_urlsafe(21). Same format as provision_tenant.mint_key and rotate_tenant_key, so there stays one format. - Writes the gtms-keys row: key_hash, tenant_id, tenant_name, tickers, board_prefix, rate_per_min, lane, entitlement, revoked=false, created_at. Conditional on attribute_not_exists(key_hash), so a re-run cannot clobber a live row. - Stores the value in Secrets Manager under --secret-name. - Writes a ledger row to Postgres app.api_key_issues, after making sure the app.tenants row its foreign key needs exists. - Prints a receipt: tenant id, secret name, key hash, board prefix, entitlement, book size, and the one command that reads the value. It does not print the key and there is no switch that makes it. The printer refuses any line containing the key before it prints anything, so a future edit that leaks the value fails loudly instead of quietly working. --verify adds one live GET https://api.steadybase.io/v1/accounts?limit=1 with the new key and prints only the HTTP status. The ledger is app.api_key_issues and not app.api_key_rotations on purpose. The rotations table is read every morning by the revoker, which revokes the key named by old_key_hash on any row past its revoke_after. An issuance has no old key, so putting one in that table means either a NULL the revoker dereferences or old_key_hash set to the new hash, which is the daily job revoking the key it just issued. ### Rotate: scripts/rotate_tenant_key.py Use it when a key has leaked: committed, pasted into a doc, printed into a transcript. The policy is rotate first, revoke later. ``` .venv\Scripts\python.exe -m scripts.rotate_tenant_key --tenant-slug the tenant ^ --old-hash --note "leaked in ..." --revoke-after-days 7 ``` You give it the sha256, never the key. Get the hash without printing the value: ``` python -c "import hashlib,sys;print(hashlib.sha256(sys.stdin.read().strip().encode()).hexdigest())" < key.txt ``` It mints a replacement with identical scope, stamps the old row with superseded_by and revoke_after, updates the Secrets Manager secret to the new value, and writes the rotation to app.api_key_rotations. Nothing is revoked. The old key keeps working for seven days so a customer who has it wired into an MCP client is not cut off with no warning. This script does print the new key once at the end. That is the one place in the system that does, it is meant for a person at a terminal, and it is why issue_tenant_key.py exists in the shape it does. ### Revoke: scripts/revoke_rotated_keys.py Scheduled task GTMSignals-KeyRevoke, 07:30 daily. For every app.api_key_rotations row with revoke_after <= today and revoked_at IS NULL, it counts requests by the OLD key hash in DynamoDB gtms-usage over the last seven days (both Lambdas log every authenticated call there). - Zero usage in seven days: sets revoked=true on the gtms-keys row and revoked_at on the ledger row. The Lambdas honour revoked on the next request. No deploy. - Any usage at all: does not revoke. It writes a loud item into the daily brief's Needs-Drew feed saying the customer may not have switched yet. A key still carrying traffic is a person still using it, and cutting them off on a timer is not a security improvement. Rows not yet due are reported with their usage count, so the switchover can be watched rather than assumed. To revoke by hand, one row, no script: ``` aws dynamodb update-item --region us-east-2 --table-name gtms-keys ^ --key "{\"key_hash\":{\"S\":\"\"}}" ^ --update-expression "SET revoked = :t" ^ --expression-attribute-values "{\":t\":{\"BOOL\":true}}" ``` ### Where the two stores are | Store | Region | Holds | | AWS Secrets Manager | us-east-2 | the value, at gtmsignals/tenant-keys/, shape {"api_key": "..."} plus non-secret metadata | | DynamoDB gtms-keys | us-east-2 | the registry: key_hash (sha256), tenant id and name, tickers, board_prefix, rate_per_min, lane, entitlement, revoked. Read by the gtm-api and gtm-mcp Lambdas on every request | DynamoDB gtms-usage holds one row per authenticated call (key_hash, ts, route), which is what makes the conditional revoke possible. --- ## 2. What a key entitles The entitlement is a named profile stored on the gtms-keys row. There is one today, navan_read, defined as a data structure in scripts/issue_tenant_key.py (ENTITLEMENT_PROFILES) so a reader can load it rather than parse a paragraph. navan_read covers: - The MCP tool surface at https://mcp.steadybase.io/mcp. the tenant's key is served 51 tools. A key without lane='private' is served 47: the four private_* tools (private_find, private_leads, private_stats, private_whats_new) are the difference. Confirmed live 2026-09-19. - The search service tenant routes: /search, /search/all, /why_now, /account_signals, /deals, /future_events, /future_events/changes, /people, /people/search, /people/personas, /company/lookup, /book, /book/summary, /ask, /changes, /bulk/company, /bulk/since, and the five trigger routes. - The account bulk pull: /accounts/bulk, /accounts/counts. - The account resolver: /accounts/resolve, plus the map routes. - The book, which is whatever tickers says on that key's row. What it can never see: contact data is OFF. A person comes back as full_name, title, seniority, function, the persona they match, linkedin_url, and the first and last time we saw them. No business email. No phone. LinkedIn URL is the identity you check a person against, and it is a public page. ### Enforced today versus declarative today This split matters and the script prints it on every run, so nobody has to take a doc's word for it. Enforced by code that already runs: - The book. gtms-keys.tickers is read by both Lambdas on every request. - The rate limit. gtms-keys.rate_per_min, same readers. - Board prefix isolation. Refused at issuance; board reads are scoped to the prefix on the row. - Revocation. gtms-keys.revoked is honoured on the next request. - No business email. people_search.entitlement() serves a validated email only out of gtm_signals.person_contacts rows owned by the calling tenant. A new tenant owns none, so include_email returns nothing for it. This is enforced by who owns the rows, not by the profile's name. - No phone. That route never serves a phone number to anybody, entitlement or not. Declarative, true only once something reads it: - The entitlement name itself. Nothing reads gtms-keys.entitlement yet. It is written now so the Lambdas can start honouring it later without a backfill. - The tool allowlist. The MCP subsets its tools by the key's lane, not by this profile. The list in the profile describes the surface; it does not gate it. - The route allowlist. No router consults the profile. - read_only. No writer checks it. Do not describe anything in the second list as enforcement. --- ## 3. Wiring a client The four snippets below are the ones from docs/MCP_NAVAN.md section 2. The endpoint is https://mcp.steadybase.io/mcp, Streamable-HTTP, JSON-RPC 2.0, stateless. Auth is Authorization: Bearer gtms_... on every request. A missing or invalid key comes back as JSON-RPC -32001 inside a normal envelope, so a client surfaces it as a tool error and not a dead transport. Read the value once, into an environment variable: ``` aws secretsmanager get-secret-value --region us-east-2 --secret-id gtmsignals/tenant-keys/andrew-lab --query SecretString --output text ``` That command prints the value to your terminal. It goes into an environment variable and nowhere else. Not into a file, not into a commit, not into a chat, not into a ticket, not pasted to an agent. If it lands in any of those, rotate. On Windows, without the value ever touching a file: ``` for /f "delims=" %i in ('aws secretsmanager get-secret-value --region us-east-2 --secret-id gtmsignals/tenant-keys/andrew-lab --query "SecretString" --output text ^| python -c "import json,sys;print(json.load(sys.stdin)[\"api_key\"])"') do setx GTM_SIGNALS_KEY %i ``` Open a new shell after setx. On macOS or Linux: ``` export GTM_SIGNALS_KEY=$(aws secretsmanager get-secret-value --region us-east-2 \ --secret-id gtmsignals/tenant-keys/andrew-lab --query SecretString --output text \ | python -c 'import json,sys;print(json.load(sys.stdin)["api_key"])') ``` In every snippet below, GTM_SIGNALS_KEY is the placeholder. The literal key never goes in the file. ### Claude Code From the project directory: ``` claude mcp add --transport http gtm-signals https://mcp.steadybase.io/mcp \ --header "Authorization: Bearer $GTM_SIGNALS_KEY" ``` Or, in the project's .mcp.json: ``` { "mcpServers": { "gtm-signals": { "type": "http", "url": "https://mcp.steadybase.io/mcp", "headers": { "Authorization": "Bearer ${GTM_SIGNALS_KEY}" } } } } ``` Check it with /mcp in the session. the tenant's key lists 51 tools; a key without the private lane lists 47. ### Codex ~/.codex/config.toml: ``` [mcp_servers.gtm-signals] url = "https://mcp.steadybase.io/mcp" [mcp_servers.gtm-signals.headers] Authorization = "Bearer ${GTM_SIGNALS_KEY}" ``` ### Cursor .cursor/mcp.json in the project, or the global one in ~/.cursor/: ``` { "mcpServers": { "gtm-signals": { "url": "https://mcp.steadybase.io/mcp", "headers": { "Authorization": "Bearer ${GTM_SIGNALS_KEY}" } } } } ``` Cursor shows the tool list under Settings, MCP. If it shows zero tools the key is missing or revoked: the server answers 401 to a keyless probe. ### Clay Clay calls it as an HTTP API rather than as a local MCP process. In a Clay table, add an HTTP API enrichment: - Method POST, URL https://mcp.steadybase.io/mcp - Headers: Authorization: Bearer , put in Clay's own secret store and not in the cell, plus Content-Type: application/json - Body, with the column you are enriching from substituted in: ``` {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "pg_account_signals", "arguments": {"ticker": "{{Ticker}}", "signal_type": "cost", "q": "travel", "limit": 5}}} ``` - The answer is JSON-RPC: the tool's own JSON is a string inside result.content[0].text, so parse that field in Clay before mapping columns. Map rows[].SUMMARY to the quote column and rows[].SOURCE_URL to the receipt column, and keep them next to each other. A quote shipped without its receipt is the thing this product exists not to do. One call per row is the pattern that works. For a whole-book pull use bulk_since on a cursor instead of a row-by-row loop. --- ## 4. Day one: loading a book, and proving the key works ### What a book is A book is the list of accounts a tenant cares about. It lives in Postgres as gtm_signals.tenant_book, one row per account per tenant: tenant_id, book_account_key, account_id, display_name, primary_ticker, domain, status (target, customer, watch, ignore), source, extras_json. the tenant's book is 4,614 rows. the tenant's is 25. The book is what "my book" questions answer out of. The tenant comes from the key, never from an argument a caller can set. Nothing about the shared facts is copied per tenant: the tenant views read the same accounts, signals and transcripts through the tenant's own lens. The key's tickers scope and the tenant's book are two different things. The tickers list on the gtms-keys row says which accounts that key may read. The tenant_book rows say which accounts are that tenant's book. Set both, and keep them consistent. ### Three ways to load one - A folder, which is the normal route. Put book.csv in tenants// next to icp.json and prefs.json. It needs a ticker and/or domain column (aliases symbol, website, company, name); optional account_key, display_name, status, source, account_id, extras_json; anything else rides in EXTRAS_JSON. Lines starting with # are skipped. Then: python -X utf8 tools\onboard_tenant.py onboard (dry run) python -X utf8 tools\onboard_tenant.py onboard --apply Every row is resolved to an account_id by ticker first, then domain. A row that resolves through neither is written with a null account_id and listed in the receipt. Nothing is invented. - The customer's own CRM export. scripts/import_tenant_accounts.py takes a Salesforce report, a HubSpot export or a spreadsheet, matches every row through scripts/account_resolver.py, writes exact matches into app.tenant_account_map and queues everything else into app.tenant_account_map_review with the candidates that made it a question. It reports the match rate by method, so the number can be argued with. Two candidates is ambiguity, never a match. - Straight into Postgres. the tenant and demo_crm live in Postgres (gtm_signals.tenant_icp, tenant_signal_prefs, tenant_book, tenant_score_rules) and reach Snowflake through publish_from_postgres.py --publish --tables TENANT_BOOK .... The publisher is tenant scoped: it deletes and reinserts only the tenant ids the staged rows contain, so a tenant Postgres does not carry is never touched. ### The first three calls a new agent should make Run these in order. They go from "is the key alive" to "is this tenant actually onboarded", which is the order things break in. 1. initialize, or tools/list. Proves the transport and the key. ``` {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}} ``` Expect a tool array. 51 tools with the private lane, 47 without. A 401 means the key is missing, wrong or revoked. 2. list_lanes. Proves the shared corpus is readable. It needs no tenant mapping and no book, so it is the cleanest "the backend is up" call there is. ``` {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "list_lanes", "arguments": {}}} ``` Expect lane counts: sec_filing, property, web_hiring and the rest. 3. resolve_entity, then one book call. resolve_entity takes query and proves the identity layer answers for this key: ``` {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "resolve_entity", "arguments": {"query": "Crocs"}}} ``` Expect resolved: true with an entity_id and ticker. Then try a book call such as pg_why_now or search. What a brand new tenant sees on day one, and why. Checked on the andrew_lab key the day it was issued: - tools/list, list_lanes, resolve_entity, company_signals and upcoming_calendar all answer. These read the shared corpus and the public company directory. - get_contacts answers no contacts file published for this tenant yet. That is the right answer, and it is also what contact data OFF looks like from the outside: an abstention in words, not an empty field you have to interpret. - search, find_companies and every pg_* or sf_* tool answer this key is not mapped to a Snowflake tenant; ask for the tenant to be onboarded. Minting a key does not onboard a tenant. Run tools/onboard_tenant.py (section 4) before expecting a book answer. - The REST board routes (GET /v1/accounts, GET /v1/whats_new on https://api.steadybase.io) return HTTP 500 while the board prefix is empty. The Lambda fetches /targets.json and raises NoSuchKey instead of returning an empty board. The key is fine; the handler is wrong. That is a bug, it is filed, and until it is fixed a new tenant's first REST call looks like an outage when it is really an unpublished board. So: mint the key, load the book, onboard the tenant, publish the board. In that order. The key is step one of four and on its own it reads the shared corpus. --- # identity (https://steadybase.io/docs/identity/) Source: ACCOUNT_IDENTITY.md sha256 c2355efa6a9c106a89e2f87dd4e60e79 ## 1. The id gtm_signals.accounts.account_id, which is acc_ plus 32 hex characters. That is the id for every account we hold, public company or subsidiary. There is exactly one id space; a subsidiary is not a second class of thing with a different kind of key. ``` acc_f88b1990cf1942c7998e5f8e56079b3b S&P Global Inc. public acc_4ef7cc624fb14fffaae04a2e9eeaaa47 International Tower Hill Mines Ltd. ``` It is minted once, never recycled, never reissued to another company, and it does not change when the company renames itself, changes ticker, moves exchange, relists, or is acquired. A row that turns out to be a duplicate is merged with lifecycle_status = 'merged' and resolved_account_id pointing at the survivor, so an id a customer already stored still resolves rather than 404s. The ticker is a convenience, never the id. Three reasons, all of them things that have already happened in our own data: - Tickers change. FB became META, FISV became FI. A join on ticker breaks silently on the day of the change and nothing errors. - Tickers are recycled and share-classed. We hold 5,815 ticker values across 5,321 accounts, plus 856 exchange-qualified tickers; BAC^N and BAC are one company. - 213,912 of our 219,233 accounts are subsidiaries and have no ticker at all. Andrew's question answers itself here: there is no ticker for a sub account, and there was never going to be one. A domain is not enough either, which is the other half of what Andrew said. Measured today: only 4,170 of 219,233 accounts carry a domain; 28 domains are claimed by more than one account (informatica.com by 37, brookfield.com by 7, tableau.net by 5); and 61 accounts carry yahoo.com as their primary domain, which is an upstream scrape defect, not an identity. A subsidiary's CRM record almost always carries the parent's website, because whoever enriched it looked the brand up rather than the entity. Section 5 is about that specific trap. --- ## 2. The resolvers we actually hold today Counts are accounts carrying at least one value of that kind, measured 2026-09-19 against the box. | Identifier | Accounts | What it is, and how far it goes | | cik | 5,173 | SEC filer id. Globally unique among filers, stable for life, never reused. The strongest public id we have for a US filer. | | lei | 7,404 | GLEIF legal entity identifier, twenty characters. Globally unique, and the only identifier here that covers subsidiaries and non-US entities on the same footing. 4,733 of them are on subsidiaries. | | ticker | 5,321 | Convenience. See above. | | exchange_ticker | 856 | NASDAQ:AAPL shaped. Better than a bare ticker, still not an id. | | domain | 4,170 | Registrable domain. Useful, not sufficient. | | duns | 0 | We have NO DUNS source. A DUNS a customer sends about its own record is stored against our account with that customer as its source and the record it arrived on as its evidence, so their next import and the next customer's join on it directly at 1.00. We never buy, infer or invent one. docs/DUNS_ROUTES.md is the Needs Andrew. | | linkedin_company | 4,369 | The company page slug, normalized. Was 3,650 on the morning of 2026-09-19; 1,163 accounts had a URL in gtm_signals.company_linkedin that had never been promoted into account_identifiers, which took public filer coverage from 69.8 to 83.5 percent for zero spend. | | legal name plus jurisdiction | 219,233 names, 7,421 with a legal jurisdiction, 4,617 with a state of incorporation | A name alone is not an identity. A name plus an agreeing jurisdiction is the standard the GLEIF anchor already uses and is rung 8 here. | | former names | 2,823 filers carry at least one SEC former name | How a customer's stale record still finds the company after a rename. | | the EX-21 parent link | 213,804 exhibit names, 195,823 of them with a jurisdiction | A subsidiary name is only an identity inside its parent's exhibit. That is why parent_hint exists. | Two numbers worth stating plainly rather than burying: - 10,140 accounts are companies we assert exist: 5,321 public filers plus 4,819 resolved subsidiaries (4,724 through GLEIF, 95 through a legacy proof). - 209,093 are EX-21 evidence: a name in an exhibit with the filing URL, not yet identified. They are served only when a caller asks for them, they come back with resolution_status: "unresolved" and is_company: false, and they are not a claim that a legal entity of that exact name exists. --- ## 3. The resolver POST /accounts/resolve on the search service, resolve on the MCP's find_companies, scripts/account_resolver.resolve() in Python. It takes any combination of account_id, cik, lei, duns, ticker, exchange_ticker, domain, linkedin_company, legal_name plus country or state, parent_hint, and a customer's own external_id plus external_system. ### The ladder Each identifier is evaluated on its own and votes for an account. Strongest first, with the confidence a lone hit earns: | # | Rung | Confidence | | 0 | account_id | 1.00 | | 1 | lei | 1.00 | | 2 | cik | 1.00 | | 3 | duns (only one a customer has told us before) | 0.97 | | 4 | exchange_ticker | 0.97 | | 5 | ticker | 0.95 | | 6 | domain | 0.92 | | 7 | linkedin_company | 0.92 | | 8 | legal_name plus an agreeing country or state | 0.85 | | 9 | former_name | 0.80 | | 10 | ex21_parent (the name is an EX-21 child of a parent that itself resolved) | 0.80 | | 11 | legal_name alone | 0.60, and flagged weak | Each further rung that agrees adds 0.03, capped at 1.00, and the answer names what corroborated it. ### The four answers, and only four - resolved: every rung that fired agrees on one account. You get account_id, the tree position (parent, ultimate parent, whether it is itself the top), method, confidence, and the evidence list of what was compared and what it hit. - conflict: two rungs resolved to different accounts. Unresolved, both named. Their row's ticker and their row's domain disagreeing is usually their CRM telling us two records were merged, and guessing in our own favour would bury it. - ambiguous: one rung matched two or more accounts. Unresolved, candidates returned. Two candidates is ambiguity, never a match, at every rung without exception. A rung stronger than the winning one that could not choose also stops the answer: if a better identifier than the one we are about to trust was unable to decide, neither do we. - no_match: nothing matched. The nearest names come back as candidates with their trigram score, labelled in words as candidates and not matches. A fuzzy name score never matches anything by itself. It only ever fills in the candidate list on an answer that has already said no. And an EX-21 evidence node is never a match. 209,093 of our 219,233 accounts are rows minted from a name in an exhibit that we have never identified. The bulk pull already excluded them (is_company: false), and the resolver did not, so on the first run over the the tenant GBV book 618 of 1,549 matches, forty percent of them, were names in somebody's exhibit: a customer would have been handed an account_id that the same API refuses to serve. They now come back as an explicit no match with the node as a labelled candidate. allow_unresolved is the deliberate opt in for a caller that wants the evidence layer, and every resolved answer carries resolution_status and is_company so the distinction is on the wire. --- ## 5. The parent domain trap A CRM record for a subsidiary carries the parent's website, because whoever enriched it looked the brand up. So the domain lands on the parent while the row's own name is the subsidiary's, and an answer of "the parent" looks right and is wrong. Measured on the synthetic book (section 7): three of the four wrong answers were exactly this shape. The guard now is: when the caller gave a name, the winning rung was not a name rung, the matched account's own name (suffix kept) does not agree with it, and that account has subsidiaries whose names are nearer, we do not answer. We hand back the parent and those subsidiaries as candidates and say which trap we think we are in. Precision went from 0.973 to 0.993 and the match rate moved by half a point. The way through it is an identifier, not a better guess: the subsidiary's LEI (4,733 of them now carry one), or the exact legal name plus a parent_hint. --- ## 6. Dedupe: whose job it is Ours. The customer dedupes on account_id and nothing else. Ticker collisions, name variants, share classes, a subsidiary sharing its parent's domain: all of that is collapsed onto one account_id before a row leaves the bulk pull. If two of our rows ever carry the same identifier under the same id_type, that is a bug on our side to report, not a merge for a customer to perform. The table's primary key is (id_type, id_value), so the database refuses it. The bulk row still carries an identifiers map so a customer CAN join on a ticker or a domain if their pipeline already does. They should not have to, and the moment they do, the failure modes in section 1 become theirs. GET /accounts/bulk says this in its own answer, in a dedupe field, on every page. --- ## 8. The customer's own ids app.tenant_account_map (migration 064), one row per (tenant_id, external_system, external_id), with the account_id, the method, the confidence, the evidence, first_seen, last_seen and reviewed_by. Once a row exists, every trigger hit, export and answer can carry the customer's own id back, which is the difference between "a signal at Salesforce" and "a signal on the record your rep already owns". Rows we refuse to guess go to app.tenant_account_map_review with the candidates and the reason (ambiguous, no_match, conflict). A row in the map is a match; nothing else is in the map. account_id is never silently re-pointed. A database trigger refuses a weaker match overwriting a stronger one unless a person signs it with reviewed_by. DUNS: we have no DUNS source and we do not buy, infer or invent one. When a customer gives us one on a row that matched some other way, it is stored as an identifier on that account so their next import matches on the DUNS directly. A DUNS another account already claims is reported as a collision and nothing is written. Doors: POST /accounts/map, /accounts/map/list, /accounts/map/review, /accounts/map/coverage on the search service; scripts/import_tenant_accounts.py for a file; find_companies with external_id plus external_system on the MCP. --- # triggers (https://steadybase.io/docs/triggers/) Source: TRIGGERS.md sha256 118221e07c0b7d6741018aaa3175f1a8 ## 1. Defining a trigger in words Three doors, one library (scripts\triggers_pg.py), so they cannot disagree. The portal page. https://the tenant.steadybase.io/portal/triggers: a name, a sentence, Define. The page shows the trigger read back in plain words before it runs, then the hits with their receipts, and a switch to turn one off. It posts to /api/portal/gtm/triggers/define, which is the box's POST /triggers/define (docs/PORTAL.md section 5). The MCP. define_trigger, list_triggers, trigger_hits, run_trigger_now at https://mcp.steadybase.io/mcp with the tenant's key; the box backed pair are pg_define_trigger and friends in H:\the tenant\mcp_server\sf_tools.py, which call the same routes through mcp_server/search_client.py. ``` define_trigger(name="CFO mentions travel spend", criteria_text="tell me when a CFO on my book mentions travel spend", scope="transcripts") ``` The route, directly. POST https://search.steadybase.io/triggers/define with a tenant gtms_ key: ``` {"name": "8-K names a new CFO", "criteria_text": "any account in my book files an 8-K naming a new CFO", "scope": "all", "filters": {"lookback_days": 90}} ``` name is the customer's label and is the key: the same name updates the trigger and keeps its hits. criteria_text is plain language. scope is transcripts, documents, signals or all. filters is optional: | key | what it does | | tickers | array, narrows every source | | sectors | array matched to the account's sector; a hit whose account has no sector is dropped | | date_from, date_to | YYYY-MM-DD source date window; date_from is a hard floor | | lookback_days | first run window when date_from is absent (default 90) | | min_score | floor on the transcript cosine (default 0.40, DEFAULT_MIN_SCORE) | | min_lexical | floor on the lexical share for documents and signals (default 0.34) | | active | false pauses the runs without losing hits | delivery is optional too and is normalised to {"slack": true|false|"C0...", "webhooks": bool, "whats_new": bool} (triggers_pg.normalize_delivery); anything else in it is dropped. The define route answers with the trigger AND its compiled matcher: the plain words are compiled on the local model in that same call (search_service.py /triggers/define calls TR.ensure_compiled), so the customer gets the read back immediately rather than after the first run. ## 3. What it drops, matches and calls borderline trigger_matcher.apply_matcher(), per candidate, best score first: | Decision | When | | drop | the source kind is not in sources; an exclude phrase is present; a topic has no phrase present; a document whose doc_type is outside doc_types; a second passage from the same call or filing when one_per_source; the judge says no | | match | every topic present, the role present when one is required, the doc type right. judged_by is matcher, judge_verdict is match | | borderline | every topic present but the role is not named in the passage or the three passages before it; a topic matched only on a single word; a lexical score within JUDGE_BAND (0.12) above the floor; or judge: always | A borderline candidate goes to the local model once, with the trigger in the customer's words, the matcher's conditions, the candidate's metadata and the passage, and it answers {"match": true|false, "why": ""}. A yes is written with judge_verdict = borderline_match and the judge's sentence appended to why_matched. A no is a drop. A model failure is a no with the reason, so an unjudged borderline never lands (judge_candidate never raises). At most JUDGE_CAP_PER_RUN (40) judge calls per trigger per run; candidates past the cap are skipped, counted in judge_skipped_cap, and not written. Role evidence looks backwards: role_context() reads the same transcript's three preceding windows, so "our CFO, Jane" said at the handover still counts for the passage where she speaks. That read runs inside a savepoint so a failure cannot poison the run's transaction. live_call. "A company in my territory captures a live call" has no subject to match, so it does not go through search at all. trigger_matcher.live_call_candidates() scans fin45.transcript_windows for transcripts whose source is one of ours (faster-whisper-small.en, whisper-large-v3, faster-whisper, whisper), on the tenant's book, in the window, with a receipt, and takes the FIRST window of each transcript as the quote. Score 1.0, score_kind: live_capture, service box:fin45.transcript_windows(live scan). A transcript with no receipt URL is not a candidate. Elsewhere, _is_live() also treats a window carrying start_sec as live, which is how a live capture satisfies a trigger whose sources include both transcript and live_call. ## 4. The hits table gtm_signals.tenant_trigger_hits, read through gtm_signals.v_tenant_trigger_hits. A row carries: | column | what it is | | quote | the passage, verbatim. Quote it as is or not at all | | receipt_url | the receipt the source already carried: the public S3 text for our own captures, https://api.steadybase.io/v1/receipt/?start=&end= for purchased text, the filing or IR document URL (with a #:~:text= fragment) for documents | | char_start, char_end | the offsets inside that source, so the receipt opens on the words | | why_matched | plain words: which topic phrases and which role evidence the matcher found, and the judge's sentence when a judge decided | | judged_by | matcher, or the local model id at its host | | judge_verdict | match, borderline_match, legacy (written before the matcher existed), unjudged (the column default) | | delivered_at | first successful delivery on any channel, else NULL | plus hit_id (sha256 of trigger, source kind, source id, so a source matched twice is one row), tenant_id, account_id, ticker, source_kind, source_id, event_date, score, details and matched_at. A source without a receipt is never written. ## 5. The two delivery refusals scripts\trigger_delivery.py. Both refusals are checked before any channel is touched. - Receipted or not delivered. receipt_ok(): a receipt_url that does not start with https://, or an empty quote, is recorded as dropped on every wanted channel and never sent. A dropped hit is final: nothing will ever make it deliverable, and load_pending excludes any hit with a dropped row. - Judged or not delivered. judged_ok(): a judge_verdict that is not match or borderline_match is skipped with that reason. This is the guard against a stale writer: a runner from before migration 049, or a scheduled task pointed at an older worktree, writes hits with the column default unjudged, and those can never be pushed to a customer as if they had been checked. A skip is deliberately NOT recorded, because the next matcher pass may still decide the hit and a recorded outcome would be a claim about a decision nobody has made. Nothing in a delivery carries an email, a phone, an account's tier or score, or another tenant's id. The Slack blocks and the webhook body are built from the hit and the trigger only, and the tracker's assert_no_contact_pii runs on the webhook payload before it is signed. ## 6. The channels, and where each is configured | Channel | Where it is configured | Target recorded | | Slack | tenant_triggers.delivery.slack: true = the tenant's configured channel, false = off, "C0..." = that channel id. For the tenant, true resolves through the tracker's slack_client.configured_channel() (H:\the tenant\the tenant-signal-tracker\scripts\slack_client.py). Any other tenant must name its channel id or the hit is skipped: no channel | the channel id | | Webhooks | app.webhook_subscriptions rows whose tenant_id is the app uuid from context.tenant_id_map for this tenant, active, with new_signal in event_kinds. A subscription of tenant A can never receive tenant B's hit | the subscription slug | | whats_new | a pull, not a push: the MCP whats_new tool reads gtm_signals.v_tenant_trigger_hits since its cursor. Nothing is sent, so nothing is recorded here | n/a | Every attempt lands one row in gtm_signals.tenant_trigger_deliveries (delivery_id, hit_id, trigger_id, tenant_id, channel, target, status in sent|queued|failed|dropped|skipped, detail, attempted_at), unique on (hit_id, channel, target). detail is filtered by _safe_detail() to outcome fields only: never a token, never a body, never a contact. The first sent or queued sets tenant_trigger_hits.delivered_at. A failed Slack post is retried on the next pass because no sent row exists for that channel. ``` python -X utf8 -m scripts.trigger_delivery --tenant the tenant --dry-run render, send nothing, record nothing python -X utf8 -m scripts.trigger_delivery --tenant the tenant --live send and record --since-days N (default 7) --limit N --hit --trigger --channel C0... --receipt PATH ``` # mcp examples (https://steadybase.io/docs/mcp/) Source: MCP_NAVAN.md sha256 1fc861ad45eca6c8459348dc96041833 ## 3. What is specific to the tenant here The tenant lens is not a filter bolted on top; it is what the answers are made of. - The book: 4,614 the tenant accounts (gtm_signals.tenant_book). Every book tool answers out of it and the tenant comes from the key, never from an argument a caller can set. - The personas: gtm_signals.tenant_icp v2, active. Nineteen travel-lane personas ranked from the CFO down through everyone who reports to the CFO, then travel, office and workplace, and the EA to the CFO: cfo, controller, chief_accounting_officer, finance_leadership, fpa, treasury, accounts_payable, procurement, finance_operations, financial_systems, expense_management, payroll, shared_services, internal_audit, tax, finance_staff, travel_manager, office_workplace, executive_assistant. Four meetings and events personas exist in the lens and have zero book coverage (cro_sales, vp_sales, sales_leader, field_marketing): asking for one returns nothing, and nothing is invented to fill it. - The vocabulary: gtm_signals.tenant_signal_prefs v1.3 boosts travel, T&E, per diem, corporate travel, business travel, managed travel, spend and expense, travel billing, consolidated travel, hotel, lodging, airline, flight, SKO, sales kickoff, offsite, all hands, roadshow, EMEA, APAC, LATAM, customer event, user conference; and demotes the false friends that used to float to the top (clinical trial, boat club, consumer hotel prices, investor conference). This is why pg_why_now opens on BILL launching Bill Travel and Expensify launching Consolidated Travel Billing rather than on a generic expansion story. - Book questions go to book tools. pg_account_signals with no ticker is the lane search over the tenant's own book. search_company_signals is the same idea over the public company directory and by contract knows nothing about anyone's book, so it can never tell you whether an account is yours. ## 4. The 40 questions Grouped by the ask. Each row gives the call, the receipt kind the answer must carry, and a real excerpt from the run in subsidiaries/dry/2026-09-19/mcp_navan_acceptance.json. ### Travel and expense spend Q01. Who on my book talked about travel and expense spend in the last 90 days, in their own words? ``` search_transcripts_local("query": "travel and expense spend", "since_days": 90, "limit": 10) ``` - Receipt kind: transcript receipt (api.gtmsignals.co/v1/receipt/?start=&end=) - the tenant persona: cfo - Verdict: answered in 1.22s, 10 hit(s) - Answer: "relevance, engagement, and product consideration. One recent example is our YouTube series featuring some of our elite athletes. We remain confident these investments will help to reignite our sales trends over time as we continue to elevate our product and marketing execution. Let me now speak to o..." - Receipt: https://s3.us-east-2.amazonaws.com/the tenant.andrewcmcguire.com/transcripts/d7516c3f-7fb6-4c44-9260-ed12a3aa877f.txt (resolved, via https) Q02. Give me every verbatim T&E mention across my book this year. ``` search("query": "T&E travel expense", "kinds": ["transcripts"], "since_days": 365, "limit": 10) ``` - Receipt kind: transcript receipt - the tenant persona: cfo - Verdict: answered in 1.54s, 10 hit(s) - Answer: "Over time, we built a payments company encompassing credit cards and banking across consumers and businesses, and recently added one of the nation's only payment networks. Business payments have been a growing part of our strategy and investment agenda. We have built the nation's third largest small..." - Receipt: https://api.steadybase.io/v1/receipt/536cb8cf-4e16-40d4-b2dc-a1c298dbc815?start=10325&end=11451 (resolved, via get_receipt) Q03. What has BILL said about managing travel and spend? ``` pg_account_signals("ticker": "BILL", "since_days": 365, "limit": 25) ``` - Receipt kind: earnings_deck / filing / ir_page source_url with page_ref - Verdict: answered in 0.76s, 25 hit(s) - Answer: "Includes BILL AP/AR customers, BILL Spend and Expense spending businesses, and Embedded Solutions & Other customers." - Receipt: https://s202.q4cdn.com/561055838/files/doc_financials/2026/q1/Q1-26-Investor-Deck.pdf (resolved, via https) Q04. Which public companies put a number on their travel expense line? ``` search_company_signals("query": "travel expense", "limit": 10) ``` - Receipt kind: public signal source_url with page_ref - Verdict: answered in 0.24s, 10 hit(s) - Answer: "Non-GAAP Operating margin increased 350 basis points Y/Y due to disciplined expense management, ongoing efficiency programs, and lower variable expenses in commissions, travel and fringe benefits." - Receipt: https://s201.q4cdn.com/299643651/files/doc_financials/2020/q1/VIAV-F4Q20-Earnings-Supplementary-Slides-Web-Posting.pdf (resolved, via https) Q05. What is Cisco's estimated travel spend and what drives that number? ``` get_account("entity": "CSCO") ``` - Receipt kind: quote plus matched_rule plus the 10-K revenue basis in drivers - the tenant persona: cfo - Verdict: answered in 0.23s, 1 hit(s) - Answer: "We're right on track with the savings associated with the restructuring that we announced earlier this year, earlier this fiscal year. We said at the time we would reinvest some of that into the growt" - Receipt: (no receipt fetched back on this call) ### T&e policy Q06. Is anyone on my book tightening or restricting travel policy? ``` search_transcripts_local("query": "travel policy restrict tighten approval", "since_days": 180, "limit": 10) ``` - Receipt kind: transcript receipt - the tenant persona: cfo - Verdict: answered in 6.00s, 10 hit(s) - Answer: "And consolidated EBITDA, which is calculated pursuant to our credit agreement, was $70 million compared to $73 million a year ago. Regarding the overall logistics market, domestic transportation supply has continued to tighten, driven in large part by increased regulatory and enforcement actions ove..." - Receipt: https://api.steadybase.io/v1/receipt/03c0df8c-b3fd-4ab6-a73b-1badd0dcfc1e?start=40683&end=41463 (resolved, via get_receipt) Q07. Show me filings and IR documents that describe a travel and entertainment policy. ``` search("query": "travel and entertainment policy", "kinds": ["documents"], "since_days": 365, "limit": 10) ``` - Receipt kind: document receipt_url with page_ref - Verdict: answered in 21.00s, 10 hit(s) - Answer: "Rawden to serve as the Company’s interim Chief Financial Officer (“CFO”), reporting to the Company’s President and Chief Executive Officer (“CEO”). While acting in such capacity, Mr. Rawden will comply with all of the Company’s policies applicable to executive officers of the Company, including but ..." - Receipt: https://s23.q4cdn.com/535501839/files/doc_financials/2015/q1/1Q15-10-Q.pdf#page=60 (resolved, via https) Q08. Watch my book and tell me when a company changes its travel and expense policy. ``` pg_define_trigger("name": "the tenant QA: T&E policy change", "criteria_text": "tell me when a company on my book changes its travel and expense policy", "scope": "all", "filters": {"lookback_days": 120}) ``` - Receipt kind: compiled matcher read back in plain words (compiled_plain) - the tenant persona: travel_manager - Verdict: answered in 0.48s, 1 hit(s) - Answer: "{"TRIGGER_ID": "trg_7f7389f9c4239e433c52ca5095ba97b7bf802814aa2d1a06dcab7ae3a416bc1a", "TENANT_ID": "the tenant", "NAME": "the tenant QA: T&E policy change", "CRITERIA_TEXT": "tell me when a company on my book changes its travel and expense policy", "SCOPE": "all", "FILTERS": {"lookback_days": 120}, "SIGNAL_T..." - Receipt: (no receipt fetched back on this call) ### Corporate card Q09. Who mentioned a corporate card or purchasing card programme on a call? ``` search_transcripts_local("query": "corporate card program", "since_days": 365, "limit": 10) ``` - Receipt kind: transcript receipt - the tenant persona: expense_management - Verdict: answered in 19.91s, 10 hit(s) - Answer: "With BYOC, they can keep the corporate cards they already have, connect them to Expensify, and automatically import transactions as expenses. That removes a major adoption barrier and lets us meet customers where they already are. We also expanded our partnership footprint. We renewed our referral p..." - Receipt: https://api.steadybase.io/v1/receipt/d9998e3d-a0fc-44d1-ad6c-3789a56f93d9?start=4995&end=5814 (resolved, via get_receipt) Q10. Who owns corporate card and expense management at Cisco? ``` pg_people("ticker": "CSCO", "persona": "expense_management", "limit": 25) ``` - Receipt kind: identity only: LinkedIn URL as the person id, no email or phone - the tenant persona: expense_management - Verdict: abstained in 0.80s, 0 hit(s) - Abstained: row_count = 0 with no note: the count is explicit but the tool did not say in words why it is empty Q11. Which companies describe a spend and expense product or programme? ``` search_company_signals("query": "spend and expense", "limit": 10) ``` - Receipt kind: public signal source_url with page_ref - Verdict: answered in 9.20s, 10 hit(s) - Answer: "Non-interest expense decreased by $50 million due to lower discretionary spend and the fourth quarter’s larger FDIC special assessment." - Receipt: https://s26.q4cdn.com/577104185/files/doc_earnings/2024/q1/presentation/COLB-Q1-2024-Earnings-Presentation-FINAL.pdf (resolved, via https) ### Expense management vendor change Q12. Which companies changed an expense or travel vendor? ``` search_company_signals("query": "expense travel vendor", "lane": "vendor_change", "limit": 10) ``` - Receipt kind: public signal source_url on lane vendor_change - Verdict: answered in 0.28s, 10 hit(s) - Answer: "They've bought about 80 to 90 locations in the US. Osir integrated forward. They have now their four motion clinics. So we met with the senior executives of all these organizations and now that we int" - Receipt: https://s3.us-east-2.amazonaws.com/the tenant.andrewcmcguire.com/transcripts/ed72342b-5788-430b-84e2-92748c1c8cfc.txt (resolved, via https) Q13. What has my T&E tool consolidation trigger caught? ``` pg_trigger_hits("limit": 10) ``` - Receipt kind: hit quote plus receipt_url plus char offsets - Verdict: answered in 0.77s, 10 hit(s) - Answer: "Although the adjusted operating ratio is not a recognized financial measure defined by IFRS, it is a widely recognized measure in the transportation industry, which the Company believes provides a comparable benchmark for evaluating the Company’s performance. Also, to facilitate the comparison of bu..." - Receipt: https://tfiintl.com/wp-content/uploads/2025/2/TFII_2024_40-F_A.pdf#page=26 (resolved, via https) Q14. Tell me when a company on my book consolidates expense or travel tools. ``` pg_define_trigger("name": "the tenant QA: expense tool consolidation", "criteria_text": "a company on my book says it is consolidating its expense or travel tools onto one vendor", "scope": "all", "filters": {"lookback_days": 120}) ``` - Receipt kind: compiled matcher read back in plain words - Verdict: answered in 0.38s, 1 hit(s) - Answer: "{"TRIGGER_ID": "trg_a618d3a8bdfbd928eb56331fe1ec670159aa8f38276ed49becd530854497ac98", "TENANT_ID": "the tenant", "NAME": "the tenant QA: expense tool consolidation", "CRITERIA_TEXT": "a company on my book says it is consolidating its expense or travel tools onto one vendor", "SCOPE": "all", "FILTERS": {"look..." - Receipt: (no receipt fetched back on this call) ### Cfo and controller changes Q15. Which of my accounts got a new CFO in the last 90 days? ``` pg_people("persona": "cfo", "new_in_role_days": 90, "limit": 50) ``` - Receipt kind: identity plus role start date; LinkedIn URL as the id - the tenant persona: cfo - Verdict: not_served in 1.62s, 0 hit(s) - Answer: "(no excerpt)" - Receipt: (no receipt fetched back on this call) Q16. Which of my accounts got a new controller or chief accounting officer this quarter? ``` pg_people("persona": "controller", "new_in_role_days": 90, "limit": 50) ``` - Receipt kind: identity plus role start date - the tenant persona: controller - Verdict: not_served in 0.54s, 0 hit(s) - Answer: "(no excerpt)" - Receipt: (no receipt fetched back on this call) Q17. Find the filings that name a new chief financial officer. ``` search("query": "appointed chief financial officer", "kinds": ["documents"], "since_days": 365, "limit": 10) ``` - Receipt kind: filing receipt_url with page_ref - the tenant persona: cfo - Verdict: answered in 1.66s, 10 hit(s) - Answer: "Item 5.02. Departure of Directors or Certain Officers; Election of Directors; Appointment of Certain Officers; Compensatory Arrangements of Certain Officers. Departure of Chief Operating Officer and Chief Financial Officer On July 31, 2026, Armin Zerza stepped down for personal reasons as Chief Fina..." - Receipt: https://d18rn0p25nwr6d.cloudfront.net/CIK-0001319161/d3d6fc3a-ef3f-44b9-8b39-65470f4eb82a.pdf#page=2 (resolved, via https) Q18. What leadership changes has Cisco disclosed? ``` pg_account_signals("ticker": "CSCO", "since_days": 365, "limit": 50) ``` - Receipt kind: sec.gov filing source_url with page_ref, VERIFIED true - Verdict: answered in 7.33s, 22 hit(s) - Answer: "Thimaya Subaiya 48 Executive Vice President, Operations" - Receipt: https://www.sec.gov/Archives/edgar/data/858877/000085887726000132/csco-20260725.htm (did not resolve, via https) ### Finance org hiring Q19. Who is hiring into the finance and accounting organisation? ``` search_company_signals("query": "finance accounting", "lane": "web_hiring_posting", "limit": 10) ``` - Receipt kind: job posting URL on lane web_hiring_posting - the tenant persona: finance_leadership - Verdict: answered in 1.41s, 10 hit(s) - Answer: "Sr Manager, Accounting \| {"ats": "workday", "city": null, "state": null, "job_id": "/job/Tokyo-Tokyo-Prefecture-Japan/Sr-Manager--Accounting_R0137796", "remote": false, "category": "finance_ops", "fun" - Receipt: https://paypal.wd1.myworkdayjobs.com/en-US/jobs/job/Tokyo-Tokyo-Prefecture-Japan/Sr-Manager--Accounting_R0137796 (resolved, via https) Q20. Which companies in the directory are growing headcount? ``` search_company_signals("query": "headcount growth", "lane": "headcount", "limit": 10) ``` - Receipt kind: public signal source_url on lane headcount - Verdict: answered in 37.90s, 10 hit(s) - Answer: "Accelerating investments in 2024 , e.g., product innovation, sales and service excellence, capacity, digital & automation initiatives, bolstering future growth" - Receipt: https://s25.q4cdn.com/997382257/files/doc_financials/2022/q4/WEX-Q4-2022-Earnings-Presentation.pdf (resolved, via https) Q21. Is anybody hiring a travel manager or a global travel lead? ``` search_company_signals("query": "travel manager", "limit": 10) ``` - Receipt kind: public signal source_url - the tenant persona: travel_manager - Verdict: answered in 7.46s, 10 hit(s) - Answer: "Business Manager, Insights & Strategic Projects \| Capital One Business Travel \| {"ats": "workday", "city": null, "state": null, "job_id": "/job/McLean-VA/Business-Manager--Marketing---Best-Customer---" - Receipt: https://fa-espx-saasfaprod1.fa.ocs.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1/job/2433498 (resolved, via https) ### Travel resumption Q22. Who said travel activity is rising again? ``` search_transcripts_local("query": "travel activity increasing return to travel", "since_days": 180, "limit": 10) ``` - Receipt kind: transcript receipt - the tenant persona: cfo - Verdict: answered in 1.70s, 10 hit(s) - Answer: "We saw even stronger growth in corporate travel in San Francisco and Los Angeles, where both cities are seeing the benefits from return to office policies. Group also grew industry-wide and for Pebble Brook in Q1. We delivered strong group revenue growth primarily driven by a 7.4% increase in group ..." - Receipt: https://api.steadybase.io/v1/receipt/8ca4139f-4ac0-4421-8672-e207d8e419be?start=9200&end=10176 (resolved, via get_receipt) Q23. Who is putting people back in front of clients in person? ``` search("query": "in person client meetings travel", "kinds": ["transcripts"], "since_days": 365, "limit": 10) ``` - Receipt kind: transcript receipt - the tenant persona: cro_sales - Verdict: answered in 6.23s, 10 hit(s) - Answer: "Our corporate travel segment represents a meaningful part of our overall business, and delivers strong momentum for Yardster. In Q2, we onboarded 34 new corporate clients, collectively adding an annual billing potential of INR 2.6 billion or USD 29.5 million. On the B2C front, we continue to make go..." - Receipt: https://api.steadybase.io/v1/receipt/4e06127d-72f4-448f-ae96-909526d68a4d?start=54117&end=54994 (resolved, via get_receipt) ### Office expansion Q24. Which companies are opening offices or expanding their footprint? ``` search_company_signals("query": "office expansion new office", "lane": "expansion", "limit": 10) ``` - Receipt kind: public signal source_url on lane expansion - the tenant persona: office_workplace - Verdict: answered in 0.23s, 10 hit(s) - Answer: "Facebook just opened its 18th Seattle-area office in September 2019 and now employs more than 5,000 people across the region." - Receipt: https://s26.q4cdn.com/577104185/files/doc_financials/2019/q3/COLB-2019-Q3-Earnings-Deck.pdf (resolved, via https) Q25. Who is expanding into EMEA, APAC or LATAM? ``` search_transcripts_local("query": "EMEA APAC expansion international offices", "since_days": 365, "limit": 10) ``` - Receipt kind: transcript receipt - Verdict: answered in 10.18s, 10 hit(s) - Answer: "We just wrapped up our largest EMEA rising yet. There we announced the new Workday EU Sovereign Cloud, which will let customers run our AI-powered HR and finance solutions entirely within the EU, keeping their data local, secure, and fully controlled. Also in Q3, we established a new AI Center of Ex..." - Receipt: https://api.steadybase.io/v1/receipt/c97d1620-e965-4f80-9ae0-e7e25dae1868?start=9813&end=10533 (resolved, via get_receipt) ### M&a integration Q26. Which of my accounts has an open deal, and what is its status? ``` pg_deals("limit": 25) ``` - Receipt kind: deal receipt_url plus the consideration sentence from the filing - Verdict: not_served in 0.32s, 0 hit(s) - Answer: "(no excerpt)" - Receipt: (no receipt fetched back on this call) Q27. Who is talking about integrating an acquisition and the synergies from it? ``` search_transcripts_local("query": "integration synergies acquisition", "since_days": 365, "limit": 10) ``` - Receipt kind: transcript receipt - Verdict: answered in 1.86s, 10 hit(s) - Answer: "So we feel good about this and we reflected that in our guide. Okay. And maybe quickly just for Neil, Neil, you outlined the synergy expectation here. Can you just give us a bit more details in terms of how to think about what sort of required for the acquisitions to go from EPS accretive to potenti..." - Receipt: https://api.steadybase.io/v1/receipt/a8a61cf6-3a84-4976-aa80-e07bc92fbb70?start=21491&end=22225 (resolved, via get_receipt) ### Cost programs Q28. Which companies announced a cost reduction programme? ``` search_company_signals("query": "cost reduction program savings", "lane": "cost", "limit": 10) ``` - Receipt kind: public signal source_url on lane cost - the tenant persona: cfo - Verdict: answered in 0.24s, 10 hit(s) - Answer: "Advanced cost-improvement program toward $125, $175M run-rate savings by end of 2026 through delivery of over $90M in run-rate savings at exit of 2025" - Receipt: https://s1.q4cdn.com/960380961/files/doc_financials/2025/q4/TROX-4Q25-Earnings-Presentation.pdf (resolved, via https) Q29. Who is cutting discretionary spend, travel included? ``` search_transcripts_local("query": "discretionary spending travel reduce", "since_days": 365, "limit": 10) ``` - Receipt kind: transcript receipt - the tenant persona: cfo - Verdict: answered in 1.40s, 10 hit(s) - Answer: "Group demand was most pressured. It was lower in all three months due to reduced government travel weaker international participation at conventions and conferences, and some increasing attrition. Transient demand, including leisure, held up better. It remained positive versus last year. That mix fa..." - Receipt: https://api.steadybase.io/v1/receipt/e0c2bcf3-666c-46ae-9530-4f74761705fd?start=10172&end=11043 (resolved, via get_receipt) ### Ai in finance Q30. Who is putting AI into the finance or accounting function? ``` search_transcripts_local("query": "AI automation finance accounting back office", "since_days": 365, "limit": 10) ``` - Receipt kind: transcript receipt - the tenant persona: financial_systems - Verdict: answered in 1.65s, 10 hit(s) - Answer: "As part of our broader back office transformation, we are moving to a technology-led exception-based operating model across nearly all functions, including finance, accounting, IT, and HR. Systems are increasingly handling routine workflows, allowing teams to focus on oversight. For example, expense..." - Receipt: https://api.steadybase.io/v1/receipt/098b9f1e-5a00-43cf-82ba-c48ac4ef9e20?start=15002&end=15842 (resolved, via get_receipt) Q31. Which companies are hiring for AI? ``` search_company_signals("query": "AI machine learning", "lane": "ai_hiring", "limit": 10) ``` - Receipt kind: public signal source_url on lane ai_hiring - Verdict: answered in 0.22s, 10 hit(s) - Answer: "Data Science & AI Innovation Postdoctoral Fellow in Machine Learning for Chemical Synthesis and Reactivity Prediction" - Receipt: https://novartis.wd3.myworkdayjobs.com/en-US/Novartis_Careers/job/Basel-City/Data-Science---AI-Innovation-Postdoctoral-Fellow-in-Machine-Lea (resolved, via https) ### Executive jet activity Q32. Where have company aircraft actually been flying? ``` search_company_signals("query": "aircraft", "lane": "jet_visit", "limit": 10) ``` - Receipt kind: jet_visit signal source_url (FAA registry plus ADS-B), fact wording only - Verdict: answered in 0.24s, 10 hit(s) - Answer: "Aircraft N525WS, registered to TEXTRON AVIATION INC, landed at SN13 (Albers Airport) on 2026-09-17. SN13 is 23.5 miles from the headquarters of Equity Bancshares Inc. in Wichita, KS." - Receipt: https://globe.adsb.lol/globe_history/2026/09/17/traces/86/trace_full_a69d86.json (resolved, via https) Q33. Any repeat jet patterns that look like a travel programme? ``` search_company_signals("query": "jet", "lane": "jet_pattern", "limit": 10) ``` - Receipt kind: jet_pattern signal source_url - Verdict: answered in 0.22s, 10 hit(s) - Answer: "Aircraft N846RS, registered to ARCHER AVIATION INC, landed at KSNS (Salinas Municipal Airport) 8 times between 2026-08-21 and 2026-09-16. KSNS is 20.2 miles from the headquarters of Granite Construction Incorporated in Watsonville, CA." - Receipt: https://globe.adsb.lol/globe_history/2026/08/21/traces/ab/trace_full_ab97ab.json (resolved, via https) ### Upcoming calls on the book Q34. Which of my accounts report in the next 14 days, and will we capture the call? ``` pg_future_events("days": 14, "limit": 100) ``` - Receipt kind: webcast_url or ir_page_url plus capture_plan - Verdict: answered in 4.18s, 100 hit(s) - Answer: "{"TENANT_ID": "the tenant", "EVENT_ID": "c9a5d4a4-9aac-4dca-b6e0-6cd27f6adfc4", "ACCOUNT_ID": "acc_d448f7fcf9e64a26b23b0217de1bf0b7", "TICKER": "ABVX", "COMPANY": "ABVX", "EVENT_TYPE": "earnings_call", "TITLE": null, "REPORT_DATE": "2026-09-21", "REPORT_TIME": "AMC", "CALL_TIME_ET": "16:30:00", "TIME_SOU..." - Receipt: https://edge.media-server.com/mmc/p/o5thz2vd (resolved, via https) Q35. What moved on my earnings calendar since yesterday? ``` pg_future_events_changes("limit": 25) ``` - Receipt kind: before/after row plus changed columns - Verdict: answered in 18.58s, 25 hit(s) - Answer: "{"TENANT_ID": "the tenant", "CHANGE_ID": 1, "EVENT_ID": "f7387513-7412-4d23-bfa8-73174d95dd6b", "CHANGE_KIND": "insert", "CHANGED_AT": "2026-09-17T15:59:24.412866-07:00", "TICKER": "HUBG", "ACCOUNT_ID": "acc_f4270f6e662245a29f78c34ba86570e8", "REPORT_DATE": "2026-09-17", "CHANGED_COLUMNS": null, "BEFORE_..." - Receipt: (no receipt fetched back on this call) Q36. When is Cisco's next earnings call? ``` next_earnings_call("ticker": "CSCO") ``` - Receipt kind: dated event with an IR or webcast URL - Verdict: answered in 0.25s, 1 hit(s) - Answer: "{"event_id": "2f9e57dc-b34f-4c64-bee6-1b61a25b09f2", "ticker": "CSCO", "company": "Cisco Systems, Inc.", "event_type": "earnings_call", "title": null, "date": "2026-11-11", "time_et": null, "time_source": "none", "webcast_url": null, "ir_url": "https://investor.cisco.com/home/default.aspx", "captura..." - Receipt: (no receipt fetched back on this call) ### Who to call and why now Q37. Give me the top ten accounts to work this week and the reason in the company's own words. ``` pg_why_now("limit": 10) ``` - Receipt kind: RECEIPT_URL plus RECEIPT_KIND plus SIGNAL_KEY - Verdict: answered in 0.82s, 10 hit(s) - Answer: "BILL Holdings, Inc. (BILL) earnings expansion: In addition, we recently launched Bill Travel, a new spend and expense product to manage travel and spend in one single connected workflow. By streamlining t..." - Receipt: https://s202.q4cdn.com/561055838/files/doc_financials/2026/q4/Q4-26-Investor-Deck.pdf (resolved, via https) Q38. Which accounts are P1 right now and why? ``` get_priorities("tier": "P1", "limit": 10) ``` - Receipt kind: quote plus matched_rule plus evidence tier - Verdict: answered in 0.30s, 10 hit(s) - Answer: "{"priority": "P1", "rank": 1, "rank_score": 0.945, "ticker": "INTU", "company": "Intuit Inc.", "sector": "Technology", "flag": "", "business": "The Intuit platform puts a team of AI agents and trusted experts by your side.", "motion": "displacement", "red_flags": [], "watch_trigger": "", "signal_str..." - Receipt: (no receipt fetched back on this call) Q39. Who is the one person to call at Expensify? ``` pg_people("ticker": "EXFY", "persona": "cfo", "limit": 10) ``` - Receipt kind: identity only plus LinkedIn URL - the tenant persona: cfo - Verdict: abstained in 0.41s, 0 hit(s) - Abstained: row_count = 0 with no note: the count is explicit but the tool did not say in words why it is empty ### Warm paths Q40. What is my warm path into Cisco, and can I open the receipt behind the reason? ``` get_account("entity": "CSCO") ``` - Receipt kind: warm_path plus the quote's own receipt, resolvable through get_receipt - Verdict: answered in 0.23s, 1 hit(s) - Answer: "We're right on track with the savings associated with the restructuring that we announced earlier this year, earlier this fiscal year. We said at the time we would reinvest some of that into the growt" - Receipt: (no receipt fetched back on this call)