# Post-tool tier (/docs/plugins/post-tool-tier)



`contributePostToolTier` is the plugin slot for uniform enrichment
that runs after every batch of tool calls completes. The same
hook covers citations, entity extraction, quality scoring,
safety flags — anything that should fire across whatever tool
ran without baking the logic into each tool definition.

It's the seam vertical-AI-startup plugins reach for
when they want to add a vertical-specific enrichment pass without
forking the substrate or touching every tool.

<SourceMeta source="{ label: &#x22;examples/plugins/domain-entity-recognition/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/examples/plugins/domain-entity-recognition&#x22; }" />

## The contract [#the-contract]

```typescript
contributePostToolTier?(): PostToolTierExecutor | null | undefined

type PostToolTierExecutor = (
  completedTools: readonly CompletedToolCall[],
) => Promise<readonly PostToolTierRow[]>;
```

One contribution per plugin (first-wins on the hook level —
multiple plugins compose at the registration-order level inside
the substrate's compiled graph). The executor receives the full
batch of completed tool calls for the turn and returns one or
more typed rows. The substrate writes those rows alongside the
tool results in the event log.

## The 4 stage hooks [#the-4-stage-hooks]

Inside the compiled graph, the post-tool tier dispatches into 4
named stages — each one is agnostic-by-injection: the node body
is domain-free; the host (or a plugin) supplies the executor at
runtime.

| Stage          | Executor field                | What it produces                                                       |
| -------------- | ----------------------------- | ---------------------------------------------------------------------- |
| `enrichment`   | `config.enrichmentExecutor`   | Domain-specific enrichment rows (entity recognition, vertical lookups) |
| `safetyReview` | `config.safetyReviewExecutor` | Safety flags, refusal triggers, policy-bound rewrites                  |
| `quality`      | `config.qualityEvaluator`     | Quality scores against the tool batch                                  |
| `citation`     | `config.citationExtractor`    | Citation rows attributed to source tool calls                          |

`contributePostToolTier` is the plugin-facing form; the four
`config.*Executor` fields are the equivalent at runtime
construction time. Hosts can wire either way:

```typescript
// Plugin-facing
const myPlugin = definePleachPlugin({
  capabilities: {
    _raw: {
      contributePostToolTier: () => myEntityRecognizer,
    },
  },
});

// Runtime-construction-facing (host strategy)
const runtime = new SessionRuntime({
  enrichmentExecutor: myEntityRecognizer,
  citationExtractor: myCitationExtractor,
  // …
});
```

The plugin-facing path is the recommended one for any plugin
that ships outside the host repository — it composes the same as
any other contribution, and the host doesn't have to know about
the plugin's enrichment to wire it.

## Worked example: vertical entity recognizer [#worked-example-vertical-entity-recognizer]

The canonical reference is
[`examples/plugins/domain-entity-recognition/`](https://github.com/pleachhq/core/tree/main/examples/plugins/domain-entity-recognition)
— a fictional "legal contracts" vertical that recognizes three
entity types:

| Entity type | What it matches                                                                    |
| ----------- | ---------------------------------------------------------------------------------- |
| `party`     | Capitalized name(s) ending in `Corp.` / `Inc.` / `LLC` / `Industries` / `Holdings` |
| `money`     | `$` followed by digits with commas + optional decimals                             |
| `date`      | ISO-8601 `YYYY-MM-DD`                                                              |

```javascript
const PARTY_RE =
  /\b([A-Z][A-Za-z&.'-]*(?:\s+[A-Z][A-Za-z&.'-]*)*\s+(?:Corp\.?|Inc\.?|LLC|Ltd\.?|Industries|Holdings|Group|Partners|Company|Co\.))/g;
const MONEY_RE = /\$\d{1,3}(?:,\d{3})*(?:\.\d+)?/g;
const DATE_RE  = /\b\d{4}-\d{2}-\d{2}\b/g;

const contributePostToolTier = () => {
  return async (completedTools) => {
    const out = [];
    completedTools.forEach((tool, toolIndex) => {
      const result = extractResult(tool);
      const text = extractText(result);
      if (text === null) return;

      const toolCallId = extractToolCallId(tool, toolIndex);

      for (const m of text.matchAll(PARTY_RE)) {
        out.push({
          kind: "entity",
          type: "party",
          value: m[1].trim(),
          toolCallId,
          index: m.index ?? 0,
        });
      }
      for (const m of text.matchAll(MONEY_RE)) {
        out.push({
          kind: "entity",
          type: "money",
          value: m[0],
          toolCallId,
          index: m.index ?? 0,
        });
      }
      for (const m of text.matchAll(DATE_RE)) {
        out.push({
          kind: "entity",
          type: "date",
          value: m[0],
          toolCallId,
          index: m.index ?? 0,
        });
      }
    });
    return out;
  };
};

export const legalContractEntityPlugin = {
  name: "example-legal-contract-entity-recognition",
  version: "1.0.0",
  contributePostToolTier,
};
```

The runnable version (with the duck-typed result extractor and a
`node --test` smoke that extracts entities from a sample
contract) lives at
[`examples/plugins/domain-entity-recognition/`](https://github.com/pleachhq/core/tree/main/examples/plugins/domain-entity-recognition).

## The `toolCallId` attribution invariant [#the-toolcallid-attribution-invariant]

Every row the executor returns SHOULD include a `toolCallId`
field. The runtime narrows the enrichment rows back to their
originating tool call when projecting the event log — multi-tool
batches without `toolCallId` attribution collapse to "produced
by some tool in this batch," and consumer-facing surfaces
(citations, lineage diagrams, audit rows) can't render
attribution cleanly.

The example's `extractToolCallId(tool, fallbackIndex)` returns
either the tool's own `toolCallId`, its `id`, or a
`unknown-${index}` fallback so production code never silently
drops attribution.

## The duck-typed result reader [#the-duck-typed-result-reader]

Tool results across the substrate have several shapes:

```typescript
function extractText(toolResult) {
  if (typeof toolResult === "string") return toolResult;
  if (toolResult === null || typeof toolResult !== "object") return null;
  if (typeof toolResult.text === "string") return toolResult.text;
  if (typeof toolResult.content === "string") return toolResult.content;
  if (toolResult.result && typeof toolResult.result === "object" &&
      typeof toolResult.result.text === "string") {
    return toolResult.result.text;
  }
  if (toolResult.output && typeof toolResult.output === "object" &&
      typeof toolResult.output.text === "string") {
    return toolResult.output.text;
  }
  return null;
}
```

Production post-tool-tier executors cover at least these four
shapes. Different tools normalize to different result envelopes,
and the post-tool-tier seam is the one place that has to handle
all of them in one place.

## Composition across the 4 stages [#composition-across-the-4-stages]

The four stages dispatch in fixed order:
`enrichment → safetyReview → quality → citation`. Each stage's
output is visible to the next stage; the substrate threads them
through a shared context. Plugins targeting one stage don't
collide with plugins targeting another — a vertical entity
recognizer (enrichment) composes cleanly with a refusal-pattern
detector (safetyReview) and a citation extractor (citation).

If two plugins target the same stage, registration order wins —
the first plugin's executor fires first; the second sees the
output of the first as context but produces its own rows
independently.

## Honest scope-limits [#honest-scope-limits]

* **The hook is async.** Unlike `onChunk` (which is sync for
  replay-determinism reasons), `contributePostToolTier` runs
  after the tool batch completes — there's no streaming
  constraint, and the executor can call out to an API, a model,
  a database, etc.
* **One enrichment pass per turn.** The executor fires once
  after the tool batch, not once per tool. If your vertical
  needs per-tool dispatch, do it inside the executor by walking
  `completedTools` (the example does this).
* **No mutation of tool results.** The executor produces new rows;
  it doesn't rewrite the tool results that went into the event
  log. If you need to rewrite tool output before the substrate
  reads it, that's a [stream observer](/docs/plugins/stream-observers)
  `amend` verdict, not a post-tool-tier executor.
* **Not a graph-node replacement.** The four stages exist as
  named slots in the compiled graph. `extraGraphNodes` is the
  hook for adding *new* nodes to the lattice; `contributePostToolTier`
  fills *existing* slots in the lattice.

## What this enables for verticals [#what-this-enables-for-verticals]

The four stages cover the typical vertical's enrichment needs:

| Vertical              | Stage(s) reached                                                                            |
| --------------------- | ------------------------------------------------------------------------------------------- |
| Legal contract review | `enrichment` (entity recognition: parties / money / dates) + `citation` (clause references) |
| Medical chart review  | `enrichment` (ICD codes / drug names) + `safetyReview` (drug-interaction warnings)          |
| Regulatory filing     | `enrichment` (form fields / dates) + `quality` (completeness scoring)                       |
| Code review           | `enrichment` (symbol names / file paths) + `safetyReview` (license violations)              |

The pattern is identical: swap the recognizer body, keep the
seam. The plugin contract guarantees the host doesn't have to
know about the vertical for the wiring to work.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Authoring" href="/docs/plugins/authoring" description="The full HarnessPlugin hook surface." />

  <Card title="Stream observers" href="/docs/plugins/stream-observers" description="Per-chunk inspection — the other half of the plugin contract." />

  <Card title="Auditable call row" href="/docs/auditable-call-row" description="The audit row enrichment rows ride alongside." />

  <Card title="Plugin contract" href="/docs/plugin-contract" description="The structural-invariant view — what plugins can and can't do." />
</Cards>
