Worked example — an agentic GTM system
Observe → Decide → Act → Learn, with six outcomes and four agents. Everything an agent says about a customer here is copy. Every number comes from Hunter-Seeker and is signed.
This page uses go-to-market vocabulary because it is familiar. Nothing in it is specific to marketing: swap the six outcomes for collections, claims, or machine failure and every call is the same.
It is runnable. Every block tagged hs:run is a literal tool input that executes as-is against
the free samples — no account, no upload, no quota. Where a value can only exist at
runtime it is written $name, meaning "the value called name in the response before it".
sample:saas_churn stands in for the churned outcome throughout, so you can run the whole loop
before you have a feature table of your own.
The rule the system is built around
Agents never author a score. An outreach agent that wants to know who to work today queries
Hunter-Seeker, or reads a Verdict that Hunter-Seeker already wrote into the CRM, and cites the
verdict_id. Research and copy are the agent's. Who and why are the engine's.
The six outcomes
Each is one analysis — its own analysis_id, its own model_ref, its own drift history.
| Outcome column | Entity | Polarity | Refreshed | Runnable here |
|---|---|---|---|---|
converted |
lead | desirable | daily (PLG) / weekly (enterprise) | — |
activated |
account | desirable | weekly | — |
expanded |
account | desirable | monthly | — |
churned |
account | adverse | weekly | yes — sample:saas_churn |
replied |
contact | desirable | daily | — |
meeting_attended |
contact | desirable | weekly | — |
Polarity matters: on churned, band: act means likely to churn; on converted it means likely
to convert. Every Verdict carries outcome.polarity so no agent has to guess.
1 · Observe — make Hunter-Seeker the sensor
A thin pipeline (n8n, a warehouse job, or an ingest agent) maintains one feature table per outcome: one row per entity, features that were knowable before the outcome, and a timestamp so the engine can hold out time.
account_id, snapshot_at, pricing_views_30d, product_events_30d, sms_reply_latency_min,
tenure_days, last_touch_days, campaign_source, plan, seats, churned
Two shapes work. If your data is already one row per account, send it as is. If you only have an
event log (page views, emails, replies, logins), send the events with a reading and the engine
reduces them to one row per entity as of a cutoff, leakage-safe. See
the self-improvement example, which does exactly that.
Two rules. First: you supply the outcome, or a reading derives it from your events; the engine
never guesses which token means the outcome occurred. That second half is new — panel@1 derives
the label from a future window or a Mann-Kendall trend, and stream@1 derives it from fault
intervals — and it is still not a guess: you name the column and the rule, and the engine applies
it. Second: any column completed after the outcome (cancel_date, closed_lost_reason) is
leakage — the leak guard will quarantine it and say why. Hash person-level ids before upload.
The Discover call, per outcome, on its cadence. Here it is against the free sample, so you can run it now:
{
"tool": "hs_rank_topk",
"arguments": {
"data": { "dataset_id": "sample:saas_churn" },
"entity_column": "customer_id",
"outcome_column": "churned",
"subject_kind": "org",
"outcome_is_desirable": false,
"horizon": "30 days",
"page": { "k": 20 }
}
}
On your own data the data block becomes {"dataset_id": "ds_gtm_churn_2026w35"}, and from the
second cycle on you add "refit_of": "$previous_model_ref" — which is what makes the response carry
a drift block.
What comes back when the finding clears the bar:
entities[]— the ranked list, each with a calibratedscore, an engine-authoredbandand amax_autonomyceilingmodel_ref(90 days) for decision-time scoring;ranking_ref(1 hour) for free interrogation- a signed run-level
verdict+signature drift— present when you passedrefit_ofleak_guard[],top_decile_lift,validation,calibration— the trust block
And what comes back when it doesn't: an honest-empty with reasons, no verdict, no model_ref,
and the run refunded. That is an Observe signal, not a failure — see
Refusal is a result. "No churn pattern cleared 1.5× this week" is a
fact the whole system reacts to (§4).
Then, free, as often as needed:
{ "tool": "hs_model_quality", "arguments": { "ranking_ref": "$ranking_ref" } }
{ "tool": "hs_explain_drivers", "arguments": { "ranking_ref": "$ranking_ref" } }
{ "tool": "hs_context_brief", "arguments": { "ranking_ref": "$ranking_ref", "format": "markdown" } }
Stop if top_decile_lift is low or leak_guard is non-empty. The pattern comes back structured,
for the agent to phrase as one unit:
"Accounts with two or more pricing views in 30 days, an SMS reply latency under five minutes, and fewer than four seats, together, convert at N× the base rate."
The N is pattern.lift, the engine's number, and you read it out of the response rather than out of
this page. The conditions carry operator and missing_values; the campaign agent builds its
audience filter from those two fields, not from the English direction word. "lower than t" means
operator: "<=" with missing_values: "included" — it holds at the threshold and covers rows
where the value is missing, so a naive < t filter selects a different, smaller cohort than the one
coverage counts.
2 · Decide — one entity at a time
Batch ranking tells the team who to work today. Agents work one entity at a time, so the outreach
agent scores at the moment of decision, using a model_ref and a row from the run above:
{
"tool": "hs_score_entity",
"arguments": {
"model_ref": "$model_ref",
"entity_id": "$an_entity_id_from_step_1",
"row": {
"tenure_months": 4,
"support_tickets": 7,
"logins_last_30d": 2,
"monthly_charges": 120,
"plan": "basic",
"seats": 3
},
"subject_kind": "org"
}
}
It returns the calibrated score, the engine-authored band (act / escalate / refuse) with a
band_reason, a max_autonomy ceiling, up to four principal_reasons in the shape adverse-action
rules require, and a signed verdict + signature.
The band is a fact about likelihood; the autonomy hint is how much rope this decision earns.
Whether "likely" is good or bad is verdict.outcome.polarity, and judging it is yours. refuse
bills nothing and carries a reason.
Before anything downstream relies on it:
{ "tool": "hs_verify_verdict", "arguments": { "verdict": "$verdict", "signature": "$signature" } }
Verification is free, so the QA agent checks everything. Over REST (POST /v1/verify-verdict) and
in the hs-verify / @hunter-seeker/verify libraries it takes no credential at all, which is what
makes it something you can hand to a reviewer who does not trust you.
3 · Act — the agents, and what each may do
| Agent | May | May not | The gate |
|---|---|---|---|
| SDR / outreach | pick from today's ranking or score the entity in front of it; write the email; research the account | choose an account outside the scored population; state a number the Verdict does not contain | acts at max_autonomy; on escalate a human sees the Verdict beside the draft |
| Campaign optimiser | use pattern.conditions as the audience definition; test creative and channel |
invent a segment; reorder or drop a condition | audience filters are built from operator + missing_values |
| CS / expansion | work the churned and expanded rankings; use principal_reasons and levers as the starting point for a play |
present a lever as a cause or a promise | subject_kind: "org" here; if contacts are people, L2 at most |
| QA / governance | verify every Verdict; attach verdict_id to any customer-facing or spend action |
approve an action whose Verdict fails verification or has expired | the last step before the outbox |
The Verdict travels with the action:
{ "action": "send_sequence", "account_id": "acct_4419",
"provenance": { "verdict_id": "$verdict_id", "model_ref": "$model_ref", "band": "act",
"max_autonomy": "L3", "kid": "2026-q3" } }
Where the drafted play comes from. The engine returns levers and principal reasons; the play ("call — offer the annual plan") is copy, written by the CS agent or by the operator's own playbook layer. The engine does not draft actions.
The band gate, in real framework code
LangGraph (langgraph 0.2.x, langchain-mcp-adapters 0.1.x):
from langgraph.types import interrupt
def gate(state):
band = state["verdict"]["entities"][0]["band"]
if band == "act":
return state # proceed at max_autonomy
# PLACE THE INTERRUPT AFTER ANY SIDE-EFFECTING CALL IN THIS NODE, NEVER BEFORE.
# interrupt() raises; on resume LangGraph RE-RUNS THE WHOLE NODE from the top, so
# anything above this line executes twice. hs_report_outcome is safe to sit above it
# because it takes an event_id and the same id twice is one row. An outbound email is
# not, and belongs in a node of its own after the gate.
decision = interrupt({
"band": band,
"band_reason": state["verdict"]["entities"][0]["band_reason"],
"max_autonomy": state["verdict"]["entities"][0]["max_autonomy"],
"verdict_id": state["verdict"]["verdict_id"],
})
return {**state, "human_decision": decision}
n8n (1.6x, MCP Client Tool node):
MCP Client Tool (hs_score_entity)
↓
IF {{ $json.entity.band === "act" }}
true → Send Email
false → Slack (approval request, includes verdict_id) → Wait → Send Email
Use Bearer auth with an hsk_ machine key on the n8n node: a workflow runs with no human present,
which is the case the browser OAuth flow is the wrong on-ramp for.
4 · Learn — close the loop, and hold out a control arm
Most agentic systems "learn" by putting more text in a vector store. That is memory. The learner here is the engine, and it learns only through explicit steps.
Randomise first. Before you act, split the act cohort by a seeded coin flip and withhold
action on 10–20% of it. Without that split, the acted group was selected by the model's own score
and every later comparison is observational. The how-to, the seeded splitter, and the remedies if
you cannot randomise are on the control-arm page — this example does not
re-explain them.
Attest what you did, for the treated arm only. When the CS agent's play changes a feature a lever named:
{ "tool": "hs_attest_action",
"arguments": { "model_ref": "$model_ref", "entity_id": "acct_4419",
"lever_token": "$lever_token", "post_value": 5,
"acted_at": "2026-09-02T10:00:00Z" } }
The lever_token comes from hs_explain_levers; only a hash of it is stored, never the token.
Report what happened, for both arms. When the real-world outcome lands — from the CRM webhook, the billing system, the calendar:
{ "tool": "hs_report_outcome",
"arguments": { "model_ref": "$model_ref",
"outcomes": [ { "entity_id": "acct_4419", "outcome": 1,
"observed_at": "2026-09-30", "event_id": "crm-evt-88213" } ] } }
The outcome is the observed binary — never the agent's opinion, never read from the trace.
event_id makes retries safe: the same id twice is one row. Reporting never changes the model.
Outcomes are evidence.
Read the evidence.
{ "tool": "hs_action_evidence", "arguments": { "model_ref": "$model_ref" } }
Run it today and you will get live: null with the floor stated beside it — n_min: 30,
small_n_below: 100. That is the floor working, not a gap: a difference computed on a handful of
entities is noise with an interval drawn around it. [live: S3]
The run's own out-of-sample estimate lives in hs_model_quality; the two are never merged.
Read the drift. On every refresh — that is, on any run that passed refit_of:
{ "tool": "hs_drift_status", "arguments": { "model_ref": "$model_ref" } }
Labels and directions only: pattern, pattern_diff (conditions added or removed, thresholds
moved and which way), and a recommendation. The orchestrator reacts to recommendation, never to
a coefficient it does not see. A pricing change or a product launch shows up here first.
Rediscovery replaces prompt-tweaking. On refit, the next cycle's hs_rank_topk(refit_of)
finds the new pattern; the orchestrator retires the audience definitions built on the old one and
adopts the new pattern.conditions. On abandon — two consecutive honest-empties — the system
stops working that outcome until the data changes.
Refusals are negative knowledge. An honest-empty on churned this week means the CS agent does
not run the win-back sequence this week. A refuse band on an account means the SDR does not call
it. Both are recorded, both are cited, and neither is retried on the same data.
5 · The Observe event bus
| Event | Source | Typical reaction |
|---|---|---|
run.completed (cleared) |
refresh | update rankings in CRM; refresh audiences from pattern.conditions |
run.honest_empty |
refresh | pause plays on that outcome; log negative knowledge |
verdict.drift with refit |
a refresh run carrying refit_of |
freeze old playbooks; force ICP review; refit |
verdict.drift with abandon |
two consecutive empties | stop working the outcome; escalate to a human |
evidence.updated |
ledger | report lift to the team; keep or drop the play |
escalate band |
any decision | route to a human; record the answer — it becomes a label |
Filled from a real run of this example: run.honest_empty fires on the raw event table in
the self-improvement example, whose §1 is an honest
non-finding recorded rather than retried.
6 · Cadence and cost
| Step | Tool | Cost |
|---|---|---|
| Discover / refit, per outcome, per cycle | hs_rank_topk |
one run, refunded on honest-empty |
| Interrogate, brief, verify, attest, report, evidence, drift | the rest | free |
| Decision-time scoring | hs_score_entity / hs_score_batch |
one decision per non-refused row |
Six outcomes refreshed weekly is six runs a week. A thousand SDR decisions a day is a thousand decisions. Verification is free forever.
Which tools exist today, which are specified but unserved, and which are only proposed, is on the status page — rendered from the registry, so this example does not carry its own copy of that list.
7 · What generalises, and what to change
Swap the table and this page is a collections system (paid_in_full, promise_kept,
right_party_contact), a claims desk (fast_track_eligible, siu_referral), or a fleet
(failed_within_30d). The constraints are the engine's, not the domain's, and they are quoted
below straight from hs_describe_capabilities rather than typed:
Input. tabular rows + outcome_column (binary, historically known) + entity_column + subject_kind
Problem shape. rank any list of entities by likelihood of a yes/no outcome; optional counterfactual levers per ranked entity
Not for. predicting a continuous number; data with no historical outcome column; pure time-series forecasting; causal guarantees.
And the four guarantees the same response publishes, verbatim:
determinism— identical inputs return byte-identical envelopesprovenance— every response carries engine_version + core_hashleak_guard— LIVE (engine 0.1.1): any column whose values predict the outcome almost perfectly (univariate AUC > 0.98 on datasets of ~200+ rows) is quarantined as likely target leakage, dropped from the analysis, and surfaced with a plain-English reason. On a cleared finding leak_guard lists the quarantined columns as { column, reason } entries (empty array when none were flagged); it is null ONLY when there is no finding (honest-null), not because the feature is unbuilt. The lift >= 1.5 gate does NOT catch leakage (leaky features make lift look excellent), so you must still verify the outcome column and every feature are historically known BEFORE the outcome; do not treat a null leak_guard as a clean bill of health.honest_empty— below a lift >= 1.5 bar measured on every analyzed row (none held back) you get {result:"none", reasons} - never weak rankings
Two bounds the engine applies are deliberately absent from this list: the minimum row count and the workable outcome-rate band. Neither is published on any wire surface, and a number that cannot be read off a response is a number this page must not print.
See also: Self-improving agents · The two loops · The randomized control arm · Refusal is a result