pleach
Plugins

Post-tool tier

The contributePostToolTier hook (C10 plugin slot) — uniform enrichment across tool batches for citations, entities, quality scores, and safety flags.

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.

The contract

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

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.

StageExecutor fieldWhat it produces
enrichmentconfig.enrichmentExecutorDomain-specific enrichment rows (entity recognition, vertical lookups)
safetyReviewconfig.safetyReviewExecutorSafety flags, refusal triggers, policy-bound rewrites
qualityconfig.qualityEvaluatorQuality scores against the tool batch
citationconfig.citationExtractorCitation 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:

// 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

The canonical reference is examples/plugins/domain-entity-recognition/ — a fictional "legal contracts" vertical that recognizes three entity types:

Entity typeWhat it matches
partyCapitalized name(s) ending in Corp. / Inc. / LLC / Industries / Holdings
money$ followed by digits with commas + optional decimals
dateISO-8601 YYYY-MM-DD
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/.

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

Tool results across the substrate have several shapes:

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

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

  • 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 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

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

VerticalStage(s) reached
Legal contract reviewenrichment (entity recognition: parties / money / dates) + citation (clause references)
Medical chart reviewenrichment (ICD codes / drug names) + safetyReview (drug-interaction warnings)
Regulatory filingenrichment (form fields / dates) + quality (completeness scoring)
Code reviewenrichment (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

On this page