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:

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:

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

Worked example — an agentic GTM system · Hunter-Seeker