# 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](/docs/status) — 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](/docs/examples/self-improving-agents), 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:

```json hs:run
{
  "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 calibrated `score`, an engine-authored `band` and a
  `max_autonomy` ceiling
- `model_ref` (90 days) for decision-time scoring; `ranking_ref` (1 hour) for free interrogation
- a signed run-level `verdict` + `signature`
- `drift` — present when you passed `refit_of`
- `leak_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](/docs/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:

```json hs:run
{ "tool": "hs_model_quality", "arguments": { "ranking_ref": "$ranking_ref" } }
```

```json hs:run
{ "tool": "hs_explain_drivers", "arguments": { "ranking_ref": "$ranking_ref" } }
```

```json hs:run
{ "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:

```json hs:run
{
  "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:

```json hs:run
{ "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:

```json
{ "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):

```python
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](/docs/control-arm) — 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:

```json
{ "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:

```json
{ "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.**

```json hs:run
{ "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`:

```json
{ "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](/docs/examples/self-improving-agents), 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](/docs/status) — 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:

<!-- hs:gen:constraints -->
**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 envelopes
- `provenance` — every response carries engine_version + core_hash
- `leak_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.
<!-- hs:end -->

---

*See also:* [Self-improving agents](/docs/examples/self-improving-agents) ·
[The two loops](/docs/two-loops) · [The randomized control arm](/docs/control-arm) ·
[Refusal is a result](/docs/refusal-is-a-result)
