get a key
steadybase-$ docs triggers
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:

keywhat it does
tickersarray, narrows every source
sectorsarray matched to the account's sector; a hit whose account has no sector is dropped
date_from, date_toYYYY-MM-DD source date window; date_from is a hard floor
lookback_daysfirst run window when date_from is absent (default 90)
min_scorefloor on the transcript cosine (default 0.40, DEFAULT_MIN_SCORE)
min_lexicalfloor on the lexical share for documents and signals (default 0.34)
activefalse 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:

DecisionWhen
dropthe 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
matchevery topic present, the role present when one is required, the doc type right. judged_by is matcher, judge_verdict is match
borderlineevery 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": "<one sentence>"}. 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:

columnwhat it is
quotethe passage, verbatim. Quote it as is or not at all
receipt_urlthe receipt the source already carried: the public S3 text for our own captures, https://api.steadybase.io/v1/receipt/<transcript_id>?start=&end= for purchased text, the filing or IR document URL (with a #:~:text= fragment) for documents
char_start, char_endthe offsets inside that source, so the receipt opens on the words
why_matchedplain words: which topic phrases and which role evidence the matcher found, and the judge's sentence when a judge decided
judged_bymatcher, or the local model id at its host
judge_verdictmatch, borderline_match, legacy (written before the matcher existed), unjudged (the column default)
delivered_atfirst 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
ChannelWhere it is configuredTarget recorded
Slacktenant_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 channelthe channel id
Webhooksapp.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 hitthe subscription slug
whats_newa 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 heren/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 <hit_id>  --trigger <id|name>  --channel C0...  --receipt PATH
sourceTRIGGERS.md
sha256118221e07c0b7d6741018aaa3175f1a8
generated2026-09-20 02:19:47 UTC
docs/docs/
steadybase-$